View Transitions and gestures: snapshots, springs, and seizing values mid-flight
document.startViewTransition snapshots the old state, your callback mutates the DOM (flushSync for React), and ::view-transition pseudo-elements animate old to new. Gestures: pointer capture, rAF-batched transforms, no layout reads in move handlers, seize values mid-flight.
The food-delivery app’s bottom sheet was the flagship interaction — and the worst-reviewed one. On iPhones it tracked the finger; on a mid-range Android it crawled at 12 fps and lagged the thumb by a visible half-second. The postmortem found two sins in one handler. First: the sheet was dragged by animating height, and every pointermove read content.offsetHeight right after writing the new height — a forced synchronous layout per event, and pointermove fires at input frequency, often two or three times per frame on a 120 Hz digitizer. Math: three forced layouts at 5–8 ms each, per frame, on a thermally-throttled SoC — the 12 fps was not mysterious, it was arithmetic. Second sin: when a spring animation was settling the sheet open and the user grabbed it again, the code waited for animation.finished before re-enabling drag — for 300 ms the sheet ignored a finger that was physically on it, which users reliably described as “broken”, not “slow”. The rewrite: measure once on pointerdown, translate the whole sheet with transform, batch writes through rAF, and on a new grab read the current transform, cancel the spring, and continue from exactly there. Same phone, 60 fps, and the reviews stopped saying broken. Direct manipulation has one law: the finger owns the value — never make the finger wait.
startViewTransition: the snapshot machine
Before FLIP and CSS transitions, route changes that animated between two genuinely different page states required keeping old and new DOM simultaneously alive and in sync — the AnimatePresence-style complexity that made animation code brittle. document.startViewTransition(callback) solves this differently: it runs a fixed protocol. The browser captures the old state — a static snapshot of the page, plus a separate snapshot for every element with a view-transition-name. It then runs your callback, which must actually mutate the DOM (and may return a promise the browser awaits). When the callback settles, the browser captures the new state, builds a tree of pseudo-elements layered over the page — ::view-transition at the root, and per name a ::view-transition-group containing an ::view-transition-image-pair with ::view-transition-old (the static bitmap) and ::view-transition-new (a live representation of the new content) — and animates between them. The default is a crossfade for the root plus position-and-size interpolation for each named group; everything is customizable with plain CSS animations targeting the pseudo-elements, because that is all they are: elements the browser owns, styled with CSS you write.
The naming contract matters: view-transition-name must be unique among captured elements in each state — a duplicate makes the transition skip (the ready promise rejects), which is the classic bug when a list of cards all get view-transition-name: card from one CSS rule. And the API is same-document by default; cross-document MPA transitions opt in with the @view-transition { navigation: auto; } CSS rule on both same-origin pages, which is what makes snapshot-based transitions work even when the old document is destroyed mid-navigation.
The React seam: flushSync or nothing visibly changes
The browser snapshots “new” when your callback settles. React state updates are queued, not synchronous — so this is the canonical integration bug:
import { flushSync } from "react-dom";
function navigate(next) {
if (!document.startViewTransition) {
setRoute(next); // graceful degradation: no animation
return;
}
document.startViewTransition(() => {
// The DOM must be in its NEW state when this callback returns.
// A bare setRoute(next) queues the update: the callback returns with the
// DOM unchanged, the browser snapshots old === new, and the real commit
// pops in AFTER the transition — fade into the same frame, then a jump.
flushSync(() => setRoute(next));
});
}flushSync forces React to render and commit synchronously inside the callback — exactly the place where the cost of a sync commit is the point, not a smell. React 19’s <ViewTransition> component wraps this machinery declaratively — it activates on transitions started with startTransition, integrates with Suspense reveals, and matches shared elements by name. Honest status: it lives in the experimental release channel only (react@experimental), not in stable React 19 — on a stable channel today, the startViewTransition + flushSync seam above is the production pattern, and it is small enough to own.
document.startViewTransition(() => setRoute(next)) produces a brief fade into an identical frame, then the new page pops in with no animation. What happened?
Gestures: one pointer model, three disciplines
Pointer events unified mouse, touch, and pen into one stream: pointerdown, pointermove, pointerup, each carrying a pointerId. The structural tool is setPointerCapture(pointerId): the element keeps receiving the stream even when the finger leaves its bounds — no document-level listener bookkeeping, no lost drags when the pointer outruns the sheet. Around that, three disciplines separate 60 fps from the Hook’s 12:
function onPointerDown(e) {
sheet.setPointerCapture(e.pointerId);
running.current?.cancel(); // seize: kill the spring mid-flight
const m = new DOMMatrixReadOnly(getComputedStyle(sheet).transform);
grabOffset.current = m.m42 - e.clientY; // continue from the CURRENT value
maxTravel.current = sheet.offsetHeight; // read layout ONCE, here
}
function onPointerMove(e) {
latestY.current = e.clientY; // store only — never read layout here
raf.current ??= requestAnimationFrame(() => {
raf.current = null; // one write per frame, not per event
sheet.style.transform = `translateY(${clamp(latestY.current + grabOffset.current)}px)`;
});
}Discipline one — rAF-batch the writes. pointermove fires at input frequency, which on modern digitizers exceeds the frame rate; writing styles per event does invisible duplicate work, and handler time directly delays the next frame. Store the latest position; write once per frame. Discipline two — no layout reads in move handlers. The Hook’s offsetHeight per move was a forced synchronous layout multiplied by event frequency; measure everything on pointerdown and cache it. Discipline three — declare intent to the browser. touch-action: none on the drag handle tells the browser the gesture is yours, so it does not wait to see whether you meant to scroll; everywhere else, leave scrolling to the compositor and keep listeners passive so it stays there.
Release and interruption: the finger owns the value
On pointerup, compute release velocity from the last few move samples and hand the value to a spring or inertia animation — WAAPI with generated keyframes, or a rAF spring if you need live retargeting. The hard rule is interruption: a new pointerdown during the animation must seize the value mid-flight — read the current transform (via getComputedStyle and DOMMatrixReadOnly, or from your own spring state), cancel the animation, and start the drag from exactly that value. The two tempting alternatives both read as defects: waiting for finished makes the surface ignore a touching finger (the Hook’s “broken”), and restarting from the animation’s target teleports the sheet under the user’s thumb. Direct manipulation inverts the usual UI contract — for buttons, input is a request; for a dragged surface, input is possession.
A bottom sheet is mid-spring toward open when the user grabs it again. The code does running.finished.then(enableDrag), so the sheet ignores the grab for ~300 ms. What is the correct interruption model?
Touch budgets: the 12 fps postmortem, quantified
Replay the Hook with numbers. pointermove at ~120 events/s on the digitizer, ~2 events per 60 Hz frame. Each handler: write height (~invalidates layout) then read offsetHeight — forced synchronous layout, 5–8 ms on a mid-range SoC. Two events per frame ≈ 10–16 ms of forced layout, plus the style/paint the height change itself dirties ≈ blown budget every frame: 12 fps. The fixed handler: one rAF-batched transform write per frame (microseconds), zero layout reads after pointerdown, touch-action: none so the browser is not second-guessing scroll intent. Same hardware, 60 fps — the difference was never the phone. Two closing notes: getCoalescedEvents() exposes the full-resolution trail of merged moves if you need ink-level precision without per-event handlers; and prefers-reduced-motion applies to gestures’ release animations too — snap instead of spring for users who asked for that.
▸Why this works
Why snapshots instead of animating the live DOM? Because the states being animated between often never coexist. A route change unmounts the old subtree in the same commit that mounts the new one — there is no moment when both the old card and the new detail view are in the DOM to be tweened, and forcing them to coexist is exactly the AnimatePresence-style complexity the platform wanted to remove. A bitmap of the old state survives the removal of the DOM that produced it; that is what makes the API work for arbitrary mutations, and what makes the MPA variant possible at all — the old document can be torn down entirely while its pixels animate. The cost of the trade: snapshots are frozen — the old side will not keep playing video or reflowing text mid-transition — and the page is briefly covered by browser-owned pseudo-elements, which is why long transitions feel like lost interactivity and 200–350 ms is the practical ceiling.
- 01Trace document.startViewTransition end to end for a React route change, including the pseudo-element tree and the two classic integration bugs.
- 02List the gesture disciplines for a 60 fps draggable surface and the interruption protocol, with the arithmetic that condemns layout reads in move handlers.
View Transitions move the hard part of state-change animation into the browser: startViewTransition captures a static snapshot of the old state — per view-transition-name and for the root — runs your callback, captures the new state, and animates between them in a browser-owned pseudo-element tree (::view-transition-group, image-pair, old, new) that you customize with ordinary CSS. The React seam is one line with a sharp edge: the DOM must be committed when the callback settles, so the setState goes through flushSync — without it the browser snapshots an unchanged DOM, animates old to old, and the commit pops in afterward. Names must be unique per captured state or the transition skips; MPA transitions opt in with the @view-transition rule and work because snapshots outlive the documents that produced them — the same property that makes the whole design work for mutations whose before and after states never coexist in the DOM. React 19’s ViewTransition component packages this for transitions and Suspense, in the experimental channel only — on stable, the flushSync seam is the production pattern. Gestures are the half of interaction the snapshot machine does not cover, and they run on three disciplines plus one law. Disciplines: rAF-batch transform writes because pointermove outpaces frames; measure on pointerdown and never read layout in a move handler — two forced layouts per frame at 5–8 ms each was the entire 12 fps postmortem; declare intent with touch-action and passive listeners so scrolling stays on the compositor. The law is possession: a finger on the surface owns the animated value, so a new grab reads the current transform, cancels the spring, and continues from exactly there — waiting for finished insults the user, and jumping to the target breaks the finger-to-pixel mapping that direct manipulation is. Now when you see a draggable surface lag behind a finger, you will know to look for layout reads in move handlers; and when you see a route change with no animation, you will check whether the DOM was actually committed before the second snapshot was taken.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.