If the dimensions of the ::view-transition-group(*)
don’t change between the old and new snapshot, you can optimize its keyframes so that the pseudo-element animates on the compositor.
~
🌟 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 ::view-transition-group()
With View Transitions, the ::view-transition-group()
pseudos are the ones that move around on the screen and whose dimensions get adjusted as part of the View Transition. You can see this in the following visualization when hovering the browser window:
The keyframes to achieve this animation are automatically generated by the browser, as detailed in step 3.9.5 of the setup transition pseudo-elements algorithm.
Set capturedElement’s group keyframes to a new CSSKeyframesRule representing the following CSS, and append it to document’s dynamic view transition style sheet:
@keyframes -ua-view-transition-group-anim-transitionName { from { transform: transform; width: width; height: height; backdrop-filter: backdropFilter; } }
Note: There are no to
keyframes because the relevant ::view-transition-group
has styles applied to it. These will be used as the to values.
~
# The Problem
While this all works there one problem with it: the width
and height
properties are always included in those keyframes, even when the size of the group does not change from its start to end position. Because the width
and height
properties are present in the keyframes, the resulting animation runs on the main thread, which is typically something you want to avoid.
Having UAs omit the width
and height
from those keyframes when they don’t change could allow the animation to run on the compositor, but OTOH that would break the predictability of things. If you were to rely on those keyframes to extract size information and the info was not there, your code would break.
TEASER: Some of the engineers on the Blink team have pondered about an optimization in which width
and height
animations would be allowed to run on the compositor under certain strict conditions. One of those conditions being that the width
and height
values don’t change between start and end. This optimization has only been exploratory so far, and at the time of writing there is no intention to dig deeper into it because of other priorities.
~
# The Technique
In a previous post I shared how you can get the old and new positions of a transitioned element yourself. This is done by calling a getBoundingClientRect
before and after the snapshotting process.
const rectBefore = document.querySelector('.box').getBoundingClientRect();
const t = document.startViewTransition(updateTheDOMSomehow);
await t.ready;
const rectAfter = document.querySelector('.box').getBoundingClientRect();
With this information available, you can calculate the delta between the start and end positions and create your own FLIP keyframes.
Instead of using a transform
for the FLIP keyframes (see why below) I will the translate
property as that one stacks together with the existing transform
on the element. The delta for both the x
and y
translation is the difference between the start and end position’s left
and top
respectively.
const flip = [
`${(rectBefore.left - rectAfter.left)}px ${(rectBefore.top - rectAfter.top)}px`,
`0px 0px`,
];
const flipKeyframes = {
translate: flip,
easing: "ease",
};
Once you have constructed the new keyframes, you can set them on the group by sniffing out the relevant animation and updating the effect’s keyframes.
const boxGroupAnimation = document.getAnimations().find((anim) => {
return anim.effect.target === document.documentElement &&
anim.effect.pseudoElement == '::view-transition-group(box)';
});
boxGroupAnimation.effect.setKeyframes(flipKeyframes);
Because the new keyframes don’t include the width
and height
properties, these animations can now run on the compositor 🙂
🤔 Why not adjust the transform
?
In theory you would be able to directly overwrite the transform as follows:
const flip = [
`translate(${rectBefore.left}px,${rectBefore.top}px)`,
`translate(${rectAfter.left}px,${rectAfter.top}px)`,
];
const flipKeyframes = {
transform: flip,
easing: "ease",
};
In practice this is not usable because the coordinates from rectBefore
and rectAfter
are viewport-relative, whereas View Transition Pseudos are laid out against the Snapshot Containing Block which – on Mobile Devices – is a different rectangle.
Check out this section from the post on how to get the positions for more details.
Setting a translate
on top of the existing transform
is not affected by this, because the transform
is already SCB-relative and the translate
only contains a delta.
UPDATE 2025.03.03 It is possible after all! To adjust the transform
directly, you must be able to read the to/from position as SCB-relative values. In https://brm.us/snapshot-containing-block I detail how this can be done.
🤔 Why not directly manipulate the existing keyframes?
You could wonder why the following is not sufficient:
const keyframes = boxGroupAnimation.effect.getKeyframes()
delete keyframes[0].width;
delete keyframes[1].width;
delete keyframes[0].height;
delete keyframes[1].height;
boxGroupAnimation.effect.setKeyframes(keyframes);
Problem here is that Chrome has a bug in which the to
keyframe is incorrectly generated and hold incorrect info.
Check out this section from the post on how to get the positions for more details.
~
# Demo
In the following demo the technique detailed above is used.
Here’s the demo that uses translate
(standalone version here):
(Instructions: click the document to trigger a change on the page)
~
# Side-by-Side Comparison
In the following demo the default generated animation and my FLIP-hijack version are shown side-by-side so that you can compare how both perform.
Especially on mobile devices the results are remarkable. To exaggerate the effect, the page also adds jank by blocking the main thread for 1 second every 5 seconds.
~
🔥 Like what you see? Want to stay in the loop? Here's how: