Beware when manipulating the coordinates of the View Transition’s ::view-transition-group(*) pseudo. Depending on where you read those coordinates from, you might end up with layout jumps when writing them back. This post details the pitfalls and how to deal with them, unlocking more performant animations on the ::view-transition-group() pseudo along the way.
~
🌟 This post is about View Transitions. If you are not familiar with the basics of it, check out this 30-min talk of mine to get up to speed.
// The subject
const $box = document.querySelector('.box');
// Get old position and size
const rectBefore = $box.getBoundingClientRect();
// Start a View Transition that alters the $box in some way
const t = document.startViewTransition(() => {
modify($box);
});
// Wait for the update callback to be done
await t.updateCallbackDone;
// Get the new position and size
const rectAfter = $box.getBoundingClientRect();
In View Transitions Applied: More performant ::view-transition-group(*) animations I used those before and after positions to create more performant keyframes by removing the width/height from the keyframes and replacing them with a translate which automatically got added to the transform defined on the ::view-transition-group(*).
While I did manage to achieve what I wanted to achieve, there was one pain point that I carefully worked my way around in all that code: the difference between the Viewport and the Snapshot Containing Block. By doing so, the solution was also limited to only working with a ::view-transition-group() whose size did not change between the old and new snapshot state.
When View Transition Pseudos get animated their positioning happens against the Snapshot Containing Block. As I wrote before:
The Snapshot Containing Block is a rectangle that is used to position the ::view-transition pseudo and its descendants.
This rectangle includes the address bar – if any – so its origin can be different from the Layout Viewport’s origin on mobile devices.
Illustration from the spec, showing the Snapshot Containing Block: The snapshot containing block includes the URL bar, as this can be scrolled away. The keyboard is included as this appears and disappears. The top and bottom bars are part of the OS rather than the browser, so they’re not included in the snapshot containing block.
In my previous posts I read the position of the ::view-transition-group() pseudo using getBoundingClientRect, which gives you get back coordinates that are relative to the Layout Viewport.
On Desktop browsers the origins of the Layout Viewport and the Snapshot Containing Block are the same, allowing you to safely read coordinates in the one space and use them in the other space.
On mobile the story is different, because on mobile there can be a difference between both origins. Because of that difference you can’t use coordinates measured in the one space to the other. As I wrote:
See the following screenshot of Chrome on Android. The box (pink outline) is positioned at the offset 24,24 when measured against the Viewport (red outline), but that becomes 24,80 when measured against the Snapshot Containing Block (blue outline).
Screenshot taken with Chrome on Android. The red outline represents the Layout Viewport. The subject is positioned at 24px from its top edge, indicated by the red arrow. The blue outline represents the Snapshot Containing Block. The subject is is positioned at 80px from its top edge, indicated by the blue arrow.
Because of this difference, it’s not safe to read coordinates of the View Transition pseudos using getBoundingClientRect(which gives you Viewport-relative coordinates) and then directly use those values in the keyframes of those pseudos (which expects Snapshot Containing Block-relative coordinates).
See the following recording that demonstrates the issue. Because the origins of the Viewport and Snapshot Containing Block do no match (on mobile), there’s a jump in position when manipulating the keyframes with coordinates there were read with getBoundingClientRect.
To be clear: there is no problem as long as you stay within the same coordinate space. For example, if you read the position of the ::view-transition-group(*) pseudo with getBoundingClientRect to draw some boxes on screen with position: fixed everything works fine because you are staying within the same coordinate space. It is only when you cross from one coordinate space into the other that you can end up with differences.
The solution to this problem is to stay within the Snapshot Containing Block coordinate space when manipulating the keyframes of the ::view-transition-group(). In theory you should be able to read the original values straight from the keyframes applied to the ::view-transition-group(*), like so:
// Get the keyframes
const boxGroupKeyframes = boxGroupAnimation.effect.getKeyframes();
// Build rect to represent the old position + size
// based off of the “from” transform value
const oldMatrix = new DOMMatrix(boxGroupKeyframes[0].transform);
const rectBefore = {
width: boxGroupKeyframes[0].width.split('px')[0],
height: boxGroupKeyframes[0].height.split('px')[0],
left: oldMatrix.e,
top: oldMatrix.f,
};
// Build rect to represent the new position + size
// based off of the “to” transform value
// ⚠️ Does not work in Chrome (see below)
const newMatrix = new DOMMatrix(boxGroupKeyframes[1].transform);
const rectAfter = {
width: boxGroupKeyframes[1].width.split('px')[0],
height: boxGroupKeyframes[1].height.split('px')[0],
left: newMatrix.e,
top: newMatrix.f,
};
However, in practice that does not work in Chrome because of crbug/387030974 which causes Chrome to compute an incorrect to keyframe. Here’s an example dump of a from and to keyframe in Chrome. All values in the to keyframe are initial values for those properties, instead of the actually used values. Uhoh!
After some conversations back and forth with our wonderful team of engineers, a workaround to crbug/387030974 was found: time travel.
I kid you not: time travel is the solution here. By advancing the time of the ::view-transition-group(*)’s animation to its endtime, you can read the to-transform from it using a regular getComputedStyle. After having done so, simply rewind the time back to 0 so that the animation can run.
In code that becomes:
let rectAfter;
if (boxGroupKeyframes[1].transform === 'none') {
// Advance animation to the end
boxGroupAnimation.currentTime = boxGroupAnimation.effect.getTiming().duration;
// Read the styles
const newStyles = window.getComputedStyle(document.documentElement, '::view-transition-group(box)');
const newMatrix = new DOMMatrix(newStyles.transform);
// Build the rectangle
rectAfter = {
width: newStyles.width.split('px')[0],
height: newStyles.height.split('px')[0],
left: newMatrix.e,
top: newMatrix.f,
};
// Rewind animation back to the start
boxGroupAnimation.currentTime = 0;
} else {
// < previous code that extracts the value from boxGroupKeyframes[1] goes here >
}
And just like that, you have the values, already in the Snapshot Containing Block Coordinate Space 🙂
🤨 Hold up, how come the translate approach – which uses Viewport-relative Coordinates (!) – even work?
Because the computed value for transition is only a delta between two points, it does not matter which coordinate space those original points were in. And because the transform is already in the Snapshot Containing Block and both properties accumulate, the end result too ended up coordinates in the Snapshot Containing Block Coordinate Space.
With the ability now to get Snapshot Containing Block-relative coordinates, it’s time to revisit the previous solution to more performant keyframes on the ::view-transition-group() pseudo.
Instead of computing a relative translate to apply on top of the existing transform, it is now possible to directly calculate a new transform
# The Solution, Applied: Dealing with differently sized elements
Because we have found a way to directly manipulate the transform property of the keyframes, it’s also possible to support View Transitions in which an element has a different size in the old and new state – something that was not possible with the translate approach.
To achieve this, set the scaleX and scaleY transform functions to the ratio of the before value over the after value:
For this approach to work properly you must not forget to set the transform-origin on the animation (in order to prevent jumps) and to also do some sizing magic on the pseudos (to play nice with changing aspect ratios).
Also note that this approach detailed in this section only works when you have no extra styles set on the ::view-transition-group(). If you, for example, have some border or border-radius set on the group, this approach here won’t work and you’ll end up with weird visual results.
Here’s a live demo (standalone version here) that has it all put together. It too works fine on both Desktop and Mobile:
# Can we, like, not have to do this Coordinate Space dance?
Even if crbug/387030974 got fixed, you’d still have to be wary about which coordinate space you are working in, both when reading and writing.
To me, ideally, you should not have to think about this and coordinates should automatically be converted to/from Viewport-relative ones when reading/writing coordinates of the ::view-transition-group() pseudos. I filed CSS Working Group Issue #11456 to discuss this.
I believe that the only proper solution would be to normalize the keyframes to be viewport relative whenever they are touched by authors:
groupAnimation.effect.getKeyframes: Expose those coordinates as viewport relative.
groupAnimation.effect.setKeyframes: Treat those coordinates as viewport relative.
Internally the SCB-relative coordinates can still be used, and my suggestion is that the engine would auto-convert back and forth between the two coordinate spaces.
That would allow:
groupAnimation.effect.getKeyframes(): authors can now use the reported coordinates to position elements onto those locations
groupAnimation.effect.setKeyframes(customKeyframes): the group pseudo no longer “jumps up” by <height-of-the-retractable-top-bar>.
groupAnimation.effect.setKeyframes(groupAnimation.effect.getKeyframes()): this still works because the engine auto-converts in both the getter and the setter.
The benefit to authors here is that don’t need to do anything: it just works.
An alternative approach would be to expose an environment variable that gives you the distance between the origin of the Viewport and the Snapshot Containing Block. You’d still need to take it into account, but at least you’d be able to mix and match coordinates from both spaces.
~
🔥 Like what you see? Want to stay in the loop? Here's how:
Bramus is a frontend web developer from Belgium, working as a Chrome Developer Relations Engineer at Google. From the moment he discovered view-source at the age of 14 (way back in 1997), he fell in love with the web and has been tinkering with it ever since (more …)
View more posts