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
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:
::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 representationTwo 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:
.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:
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:
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
- Chromium only. The feature detection is a one-liner, and the fallback is your app exactly as it is:
const transition = (update) =>
document.startViewTransition ? document.startViewTransition(update) : update()- 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.
- Motion sensitivity is your job. The API does nothing automatic for reduced motion:
@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.