JavaScript View Transitions Table
| Piece | What it does | Field note |
|---|---|---|
document.startViewTransition(cb) | Snapshot, swap, animate | Browser screenshots old state, runs cb, screenshots new - then tweens |
::view-transition-old/new(root) | The two snapshots | Pseudo-elements you style - cross-fade is the DEFAULT |
view-transition-name | Pair across states | Same name old+new = the element MORPHS position instead of fading |
names must be unique | One per snapshot | Duplicate names abort the transition - list items get id-based names |
navigation API / MPA opt-in | The trigger points | SPA: wrap your render; MPA: @view-transition { navigation: auto } |
prefers-reduced-motion | Respect the gate | Skip or shorten transitions - motion is still opt-in for some users |
transition: view-transition | Not how it works | The API is NOT a CSS transition - snapshot timing is JS-driven |
fallback = no wrapper | Feature-detect | No startViewTransition? Call cb() directly - same DOM result, no animation |
The View Transitions API mechanizes what FLIP libraries hand-rolled: document.startViewTransition(callback) screenshots the old DOM, runs your update, screenshots the new DOM, then animates between the two snapshots - cross-fade by default, true morphing when old and new share a view-transition-name.
Bottom line: the named-element morph is the headline - give the moving card the same view-transition-name in both states and it GLIDES between positions while everything else cross-fades. Names must be unique per snapshot (duplicates abort the whole transition), which is why list items take id-based names.
The honest part: the API is free to adopt because the fallback is trivial - no startViewTransition means calling your callback directly, yielding the identical DOM result without animation. Feature-detect once, wrap once, and older browsers simply skip the flourish.
How to use
- Wrap your render: document.startViewTransition(() => updateTheDOMSomehow()) - the callback must be synchronous DOM changes; async data fetching happens BEFORE the wrapper.
- Morph a moving element: old and new both get view-transition-name: hero-card - position, size and content tween together; everything unnamed cross-fades.
- Opt in MPAs with CSS: @view-transition { navigation: auto; } - cross-document transitions on same-origin navigation, no JavaScript at all.
Frequently asked questions
What does startViewTransition actually do, step by step?
Four beats: it captures a screenshot-like snapshot of the current page as pseudo-elements (::view-transition-old), pauses rendering, runs your callback (the DOM change), captures the new state (::view-transition-new), then animates between the two - cross-fade unless you restyle the pseudo-elements. The critical contract: the callback should be SYNCHRONOUS DOM updates. Data fetching belongs before the wrapper (fetch, THEN transition) - putting a slow await inside means the old page freezes mid-frame waiting for the network, which is the worst of both worlds: janky AND animated.
How does view-transition-name create the morph effect?
Names create identity across the snapshot boundary. Every element with a view-transition-name gets its OWN snapshot pair, and old and new elements sharing a name are treated as the same thing re-rendered - so the browser interpolates position and size (a transform-and-clip tween) instead of fading one out and the other in. The card that moved from grid to detail page glides there; its inner content cross-fades. The cost is the uniqueness rule: two elements with the same name in ONE snapshot abort the entire transition (the DOM still updates - the animation is the casualty), so dynamic lists assign names per id and only to the few elements that should morph.
Why does the fallback make this API safe to adopt?
Because the DOM contract is unchanged. startViewTransition returns an object, but the transition completes regardless - if the browser lacks the API, your feature-detect (if (!document.startViewTransition)) just calls the callback directly, producing exactly the same end-state DOM, minus the animation. There is no polyfill to maintain and no broken half-state to design around: animation is the enhancement, correct content is the baseline. That property - identical outcome with or without - is what separates safely adoptable APIs from ones that need defensive coding everywhere.
How do reduced-motion and the MPA variant fit in?
Both are gates on the animation, not the content. prefers-reduced-motion still governs: wrap transitions so the reduced-motion user gets the instant swap (the same fallback path) - motion remains opt-in per user setting. The MPA variant is the bigger surprise: cross-DOCUMENT transitions work with pure CSS - @view-transition { navigation: auto } opts same-origin navigations into the snapshot mechanic, so a classic multi-page site gets morphs between pages with zero JavaScript. The constraint is same-origin and the animation still has the same pseudo-element anatomy - it is the same machinery triggered by navigation instead of by script.