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.
| piece | role | analogy |
|---|---|---|
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
| intent | pattern | why |
|---|---|---|
| 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 |