The view transition API is a welcomed new addition to the browsers animations techniques. These are my study notes, exploring how they work, and their advantages and quirks.
They greatly simplify FLIP animations without using animation libraries like framer, and it’s even possible to animate navigation between pages, which was previously impossible 😋.
First steps
It all starts by calling document.startViewTransition(callback). The API captures the current state of the page. This includes taking a screenshot, which is async as it happens in the render steps of the event loop.
Once that’s complete, the callback passed to document.startViewTransition() is called. That’s where the developer changes the DOM.
Rendering is paused while this happens, so the user doesn’t see a flash of the new content. Although, the render-pausing has an aggressive timeout.
Once the DOM is changed, the API captures the new state of the page, and constructs a pseudo-element tree like this:
::view-transition
└─ ::view-transition-group(root)
└─ ::view-transition-image-pair(root)
├─ ::view-transition-old(root)
└─ ::view-transition-new(root)
The ::view-transition sits in a top-layer, over everything else on the page.
::view-transition-old(root) is a screenshot of the old state, and ::view-transition-new(root) is a live representation of the new state. Both render as CSS replaced content.
The old image animates from opacity: 1 to opacity: 0, while the new image animates from opacity: 0 to opacity: 1, creating a cross-fade.
Once the animation is complete, the ::view-transition is removed, revealing the final state underneath.
Behind the scenes, the DOM just changed, so there isn’t a time where both the old and new content existed at the same time, avoiding the accessibility, usability, and layout issues.
The animation is performed using CSS animations, so it can be customized with CSS.
In the following 101 example, we transition between a button with/without a loading with the default browser animation with duration of 3s.
Example: Animating auto-sized elements
Auto sized elements was historically a pain-in-the-ass to animate. It’s now possible to animate it, and it’s incredibly simple!
Shared elements transitions examples
A popular UX in mobile is the shared elements transitions, that animates smoothly between two views, maintaining visual continuity between elements.
Multiple Layers and ordering
The pseudo-elements ::view-transition-* sits at the highest z-index, at
front of your content even of fixed elements. It’s a good default and should solve most cases of single element animations.
However, sometimes we need to composite multiple layers together. By adding multiple view-transition-names , they’re all upgraded to pseudo-elements and can be ordered by z-index property.
.content {
view-transition-name: content;
}
::view-transition-group(content) {
z-index: 0;
}
/* Window is rendered above content */
.window {
view-transition-name: window;
}
::view-transition-group(window){
z-index: 10;
}
Transition between different DOM element
The transition can happen between different DOM elements, they only need to share the same view-transition-name. For example, to animate an image into a modal element:
thumbnail.onclick = async () => {
// We can only have one element per viewTransitionName
// So we add it dinamically to avoid duplicating it in html.
thumbnail.style.viewTransitionName = 'full-embed';
document.startViewTransition(() => {
// The modal image shares the view-transition-name of full-embed
modal.show();
// And we clean thumbnail viewTransitionName, again to avoid
// duplicates that breaks the view-transition API
thumbnail.style.viewTransitioName = '';
});
};
A more ergonomic way
Instead of using raw viewTransition, which requires manual handling of the transition lifecycle, we can leverage the useViewTransition hook provided by the react-view-transition library.
I created my own ts vanilla helper, that helps specifically on-off transitions between different DOM elements
/*
* Ergonomic viewTransition syntax
*
* ```js
* new ViewTransition([
* ["my-transition", {from: $('.my-card > img'), to: $('.my-modal > img')}]
* ]).animate(updateDOM);
* ```
*/
type ViewTransitionOptions = [string, { from?: HTMLElement; to: HTMLElement }]
export class ViewTransition {
#layers: ViewTransitionOptions[]
#callback: () => any
constructor(parameters: ViewTransitionOptions[]) {
this.#layers = parameters
}
pre_snapshot() {
for (const layer of this.#layers) {
const [name, { from: source, to: target }] = layer
if (source && target) {
// Shared element transition
source.style.viewTransitionName = name
target.style.viewTransitionName = ""
} else if (target) {
// Single element transition
target.style.viewTransitionName = name
}
}
}
async post_snapshot() {
for (const layer of this.#layers) {
const [name, { from: source, to: target }] = layer
if (source && target) {
// A shared element transition
source.style.viewTransitionName = ""
target.style.viewTransitionName = name
} else if (target) {
// Single element transition
target.style.viewTransitionName = name
}
}
return this.#callback()
}
async animate(callback: () => any) {
this.#callback = callback
if (!document.startViewTransition) {
return callback()
} else {
this.pre_snapshot()
return document.startViewTransition(this.post_snapshot.bind(this))
}
}
}
React Integration
In react world, we MUST change the state synchronously using flushSync()
document.startViewTransition(() => {
flushSync(() => {
// Update the state will cause a re-render
setStep((step + 1) % allSteps.length);
});
});
Examples
Footnotes
-
Basic View Transitions SPA demo: A basic image gallery demo1 with view transitions, featuring separate animations between old and new images, and old and new captions. ↩