← Writing
5 min read

View Transitions in practice

The API that made page transitions a CSS problem instead of a framework problem. What it actually does, the gotchas that eat an afternoon, and where it still falls short.

  • css
  • technique

For most of the web’s life, animating between two pages meant not having two pages. You built a single-page app, kept the DOM alive across navigations, and animated within it, accepting a router, a client-side rendering layer, and a pile of state management as the cost of a crossfade.

The View Transitions API removes that trade. Page transitions became a CSS problem.

What it does

At its simplest:

document.startViewTransition(() => {
  updateTheDOM();
});

The browser screenshots the current state, runs your callback, screenshots the new state, and crossfades between them. Every DOM change inside the callback appears as one animated transition rather than a jump.

The default is a crossfade of the whole page, which is already better than nothing. The interesting part is what you can do beyond it.

The pseudo-element tree

During a transition the browser builds a pseudo-element tree over the page, and everything is styled from there:

::view-transition
└── ::view-transition-group(root)
    └── ::view-transition-image-pair(root)
        ├── ::view-transition-old(root)
        └── ::view-transition-new(root)

old is the outgoing snapshot, new is the incoming one. They are just elements, so they take ordinary CSS animations:

::view-transition-old(root) {
  animation: 150ms cubic-bezier(0.32, 0, 0.67, 0) both fade-out;
}
::view-transition-new(root) {
  animation: 250ms cubic-bezier(0.33, 1, 0.68, 1) both fade-in;
}

Note the asymmetry: the outgoing state leaves faster than the incoming arrives. Same rule as everywhere else in interface motion.

Naming elements

Giving an element a view-transition-name lifts it out of the root snapshot into its own group, animated independently between its old and new geometry:

.hero { view-transition-name: hero; }
.site-header { view-transition-name: header; }

If .hero exists on both pages, the browser animates between its two positions and sizes. That is a shared element transition, handled natively. If it exists on only one, it animates in or out on its own.

This is also how you keep a persistent header from crossfading with itself. Name it, and it is treated as one continuous element rather than two snapshots dissolving.

Cross-document transitions

The genuinely new capability is transitions between separate documents: real navigations, no SPA.

@view-transition {
  navigation: auto;
}

Both pages need it. That is the entire opt-in, and it is what makes this interesting for static sites: a prerendered multi-page site can now have transitions that previously required a client-side router. Astro wires this up for you if you use its transition support, but the underlying mechanism is the CSS rule above.

The gotchas

view-transition-name must be unique at any given moment. This is the single biggest time sink. Two elements sharing a name silently disables the transition. No error, no warning, it just does not run. In a grid, assign the name to the clicked item only, immediately before the transition, and remove it after:

card.style.viewTransitionName = 'hero';
const t = document.startViewTransition(() => update());
await t.finished;
card.style.viewTransitionName = '';

Snapshots are images, not live elements. A video mid-playback, an animating spinner, a canvas: all of them freeze during the transition. For short transitions nobody notices; for anything over ~400ms it reads as a stall.

Aspect-ratio changes distort. When old and new have different proportions, the default image-pair blend stretches them. The usual fix is overriding the group’s animation to scale rather than stretch, or matching the aspect ratios.

Everything is blocked while it runs. The page is non-interactive for the duration. Another reason to keep transitions short: 200–300ms for most, not 500.

Nested named elements get complicated. A named element inside another named element is lifted into a separate group, which means it no longer clips to its parent. Overflow and border-radius often behave unexpectedly. Keep the naming shallow.

Reduced motion

startViewTransition does not consult prefers-reduced-motion for you. Handle it:

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

The transition still runs and the DOM still updates. It just resolves instantly. That is usually better than skipping startViewTransition entirely, since the code path stays the same.

Where it still falls short

No mid-transition interruption. Start a transition and it runs to completion. A user who taps twice quickly gets a queue rather than a redirect, which is exactly the case where springs shine and this API does not. Keep durations short enough that it rarely comes up.

Snapshot cost scales with page size. Very large or complex pages have a visible hitch at the snapshot boundary, particularly on mobile.

Support is not universal. It has landed in Chromium and progressed elsewhere, but you should treat it as an enhancement and check current support rather than trusting any claim in a blog post, including this one. Feature-detect:

if (!document.startViewTransition) {
  updateTheDOM();
  return;
}
document.startViewTransition(() => updateTheDOM());

That fallback is three lines and never a regression: unsupported browsers get the DOM update they would have had anyway.

Is it worth it

For a static content site, yes. It is the cheapest meaningful upgrade available. A crossfade plus a persistent header is a few lines of CSS and no JavaScript, and it removes the jarring white flash between navigations.

For an app already running a client-side router, less obviously. You likely have transition machinery, and the API’s lack of interruptibility may be a downgrade from what your animation library does.

The honest framing: View Transitions did not make page transitions better. It made them available to sites that could not previously afford them, which, for the prerendered-static world, is a bigger deal than any individual effect.