Section 4 · Motion and Architecture
View Transitions
18 min3 min readCode playground
Lesson at a glance
Animate between two states of the same page with document.startViewTransition(), give elements names so they morph into their new positions, and customise or disable the animation in CSS.
- 1Snapshot, update, animate
- 2Name elements to make them move
- 3Styling the animation
- 4Waiting for the transition
- 5Your turn
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
When a Waypoint visitor ticks “Hide sold-out”, three cards vanish and the rest jump upwards to fill the gaps. It’s instant and correct, and it’s disorienting: your eye was on the fourth card, and now something else is there. A smooth movement from the old layout to the new one would tell the eye where things went.
Animating layout changes used to mean measuring every element before and after and running FLIP animations in JavaScript. The View Transition API does the measuring for you.
Snapshot, update, animate
const button = document.querySelector('#hide-full');
function applyFilter() {
const hide = button.getAttribute('aria-pressed') !== 'true';
button.setAttribute('aria-pressed', String(hide));
for (const card of document.querySelectorAll('.session-card.is-full')) {
card.hidden = hide;
}
}
button.addEventListener('click', () => {
if (!document.startViewTransition) {
applyFilter(); // older browsers: just update
return;
}
document.startViewTransition(() => applyFilter());
});startViewTransition() does four things in order:
- Captures a snapshot of the current page.
- Calls your update function, which changes the DOM however it likes.
- Captures the new state.
- Builds a tree of pseudo-elements showing old and new snapshots, and animates between them.
The default animation is a crossfade of the whole page. It’s subtle, and already better than a jump. The DOM update itself is real and immediate; only the picture animates, and while it plays, the page underneath is already in its new state. If the update throws or the transition is skipped, the DOM change still happens.
Name elements to make them move
To make each card glide to its new position, give it a view-transition-name. Every named element gets its own snapshot pair, and the browser animates its position and size from old to new:
.session-card {
view-transition-name: var(--vt-name);
}// Give every card a unique name once, when the list renders
for (const card of document.querySelectorAll('.session-card')) {
card.style.setProperty('--vt-name', `card-${card.id}`);
}Names must be unique among the elements rendered at the moment of capture. The cards that disappear fade out (they exist only in the old snapshot), and the cards that remain slide up into the gaps. Your eye follows them.
Styling the animation
The animation lives in a tree of pseudo-elements: ::view-transition at the top, a ::view-transition-group(name) per named element (plus root for the rest of the page), and inside each, ::view-transition-old(name) and ::view-transition-new(name). They’re animated with ordinary CSS animations, so you control them with ordinary CSS:
::view-transition-group(*) {
animation-duration: 250ms;
animation-timing-function: ease-out;
}
/* Keep the header still while the list moves */
.site-header { view-transition-name: header; }
::view-transition-group(header) { animation: none; }The * matches every group. For reduced motion, remove the animations so the change is instant:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}The !important isn’t there to beat the browser’s default animations; your normal rules already win over those, as you saw in the first lesson. It’s there so the user’s preference also beats any more specific animation you or a library wrote for one named group, such as ::view-transition-group(header).
You can also target one direction of change. The old snapshot of a card that disappears only has a ::view-transition-old(card-s2), with no new counterpart, so a rule like ::view-transition-old(*) { animation-duration: 150ms; } makes leaving cards fade a little faster than the remaining ones move. Small differences like that make a list feel organised rather than shuffled.
Waiting for the transition
startViewTransition() returns a ViewTransition object with three promises. updateCallbackDone resolves when your DOM update has run, ready when the animation is about to start, and finished when it has ended and the pseudo-elements are gone. Use finished to move focus or announce a result after the motion settles, for example sending focus to the first remaining card for keyboard users. Don’t make anything essential wait on the animation, though: in a browser without the API, there’s no transition object at all.
Your turn
The exercise’s filter button hides sold-out sessions with no animation. Give each card a unique name, wrap the update in a view transition with a fallback, set the group duration to 250ms, and switch the animation off for reduced motion. The checks spy on startViewTransition and also remove it to make sure your fallback works. Next, you’ll tie animation progress to scrolling instead of time.
Key takeaways
document.startViewTransition(update)snapshots the page, runs your DOM update, snapshots again and animates between the two with a crossfade by default.- An element with a unique
view-transition-namegets its own snapshot pair, so it moves and resizes smoothly to its new position instead of fading. - The animation is built from pseudo-elements such as
::view-transition-group(name), which you style with ordinary CSS animations. - Always feature-detect
startViewTransitionand run the update directly when it’s missing, and remove the animation for users who prefer reduced motion.