View Transitions API — crossfade between DOM states

[check: …]
📖 guide (.md) ← react deep dive
📖 Pair this live React playground with the companion guide (.md) — this page is the rendered ground truth. ↗ Astro uses the same API for page transitions — here it is in pure React (same document.startViewTransition, one component instead of one route).

setState → startViewTransition(setState): the wrap

document.startViewTransition(cb) is a browser API, not a React one. You hand it a callback that changes the DOM. In React that callback holds a setState — React re-renders inside the transition. The browser then screenshots the old DOM → lets React paint the new DOM → crossfades old screenshot into new DOM. One wrap, free animation. Tag any element with view-transition-name: hero and the browser morphs that element between its old and new position/size — a shared-element (hero) transition, no FLIP math.

pieceroleanalogy
startViewTransition(cb) browser method: snapshot → run cb → crossfade the film director — yells "freeze old frame", shoots the new scene, dissolves between them
the callback sync function that updates the DOM (in React: holds your setState) the scene change — what actually moves between the two frames
::view-transition-old/new pseudo-elements the browser generates; you animate them in CSS the two film frames being dissolved
view-transition-name CSS prop that tags one element for a shared (hero) morph the match-cut — one object tracked across the cut

1 · the transition you wrap (edit me)

2 · Babel compiles JSX → element tree

<ViewTransitionDemo/> becomes React.createElement(ViewTransitionDemo). Clicking an item runs navigateTo, which calls document.startViewTransition with setView inside. React commits the new view; the browser crossfades.

// (hit "compile & render" to see Babel's output)

3 · live React (the transition, proven)

Click an item — the list crossfades into the detail view (in Chrome/Safari/Firefox 144+), then click Back to crossfade back. The gold-check drives this automatically: wait for 3 items → click item-2 → assert detail shows "Item 2" → click back → assert list returns.

current view: · gold: list(3) → item-2 → detail("Item 2") → back → list(3)

intent → pattern

intentpatternwhy
crossfade a state change document.startViewTransition(() => setX(v)) one wrap = free default crossfade, no animation lib
feature-detect if (document.startViewTransition) {…} else setX(v) Firefox <144 / old browsers: fall back to plain setState
hero / shared element style={{viewTransitionName:'hero'}} on both views browser morphs the named element old → new position/size
customise the fade ::view-transition-old(root){…} + ::view-transition-new(root){…} override the default crossfade with your own keyframes
SPA route change wrap the router's navigate() in startViewTransition page-to-page crossfade without a full reload