System preferences

Back

SECTOR 02.1Transmission open

View transitions without the framework

I gave hours to SPA router animation libraries. Three years ago I published 60fps scroll animations too, and the trick there was staying out of the browser's way. Today's trick is stronger, and it costs one function call.

The View Transitions API turns any DOM change into an animated crossfade, morph included. The browser animates for you. No framework, no FLIP math, no getBoundingClientRect bookkeeping. It shipped in Chrome 111 this March. Jake Archibald and Khushal Sagar wrote the explainer, and it is the source of everything below.

One function

CODE // TRANSMISSION03 LINES
const transition = document.startViewTransition(() => {
  list.prepend(newCard)
})

The browser captures the old state, runs your callback, captures the new state, then animates between them on the compositor. Rendering pauses only between the old capture and the end of your callback. Keep the callback synchronous and fast.

What most articles skip is what the snapshots become. The engine builds a pseudo-element tree over the page:

CODE // TRANSMISSION05 LINES
::view-transition                          overlay root, covers the viewport
└─ ::view-transition-group(name)           animates transform + size
   └─ ::view-transition-image-pair(name)   isolation: isolate
      ├─ ::view-transition-old(name)       static snapshot image
      └─ ::view-transition-new(name)       live representation

Two details in there are pure gold. ::view-transition-old is a static image. ::view-transition-new is live, so the incoming state keeps painting through the animation. The frozen-page feel comes from input hitting the overlay.

The image pair is isolated, so old and new blend with mix-blend-mode: plus-lighter. Crossfade two identical overlapping pixels and the brightness holds flat mid-transition. That anti-flicker detail is specced and almost nobody knows it exists. The default (root) transition works because the UA stylesheet ships :root { view-transition-name: root }.

The morph

Tag an element on both sides of the change and the browser morphs it, position and size:

CODE // TRANSMISSION02 LINES
.card-thumbnail { view-transition-name: hero; }
.detail-cover   { view-transition-name: hero; }

This is FLIP, implemented by the engine, minus the failure modes. Names must be unique per capture. Never name twenty grid items hero. Name on interaction, then clean up after:

CODE // TRANSMISSION03 LINES
card.style.viewTransitionName = 'hero'
const t = document.startViewTransition(() => openDetail(card))
t.finished.finally(() => (card.style.viewTransitionName = ''))

A duplicated name fails loudly. The transition skips and t.ready rejects. That promise is the API's second half. startViewTransition returns a ViewTransition object: { updateCallbackDone, ready, finished, skipTransition() }. ready resolves once the pseudo-tree exists. Await it and drive the animation with WAAPI instead of CSS:

CODE // TRANSMISSION06 LINES
const t = document.startViewTransition(updateDOM)
await t.ready
document.documentElement.animate(
  { transform: ['translateY(0)', 'translateY(-100%)'] },
  { duration: 250, pseudoElement: '::view-transition-old(root)' },
)

Three catches, honestly labeled

  1. Chromium only. The feature detection is a one-liner, and the fallback is your app exactly as it is:
CODE // TRANSMISSION02 LINES
const transition = (update) =>
  document.startViewTransition ? document.startViewTransition(update) : update()
  1. Same-document only, for now. Cross-document transitions mean real MPA navigations with zero router JS, and they are specced and in origin trial. Astro did not wait. Astro 3.0 intercepts the navigation, swaps the DOM itself, then calls this same-document API, with a ~3KB fallback for other browsers. It is the first major framework to ship it, and the best argument for the API in production today.
  2. Motion sensitivity is your job. The API does nothing automatic for reduced motion:
CODE // TRANSMISSION05 LINES
@media (prefers-reduced-motion) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation: none !important; }
}

Every SPA router animation library now lives on borrowed time. The platform keeps absorbing the patterns the ecosystem proved. Stop staying out of the browser's way. Let the browser hold the tween.