View Transitions API Implementation
Part of Modern View Transitions & Scroll APIs.
The View Transitions API gives the browser a two-snapshot rendering contract: capture the DOM before a mutation, apply the mutation, capture after, then animate the difference using generated pseudo-elements. This eliminates the hand-rolled FLIP bookkeeping and rAF loops that developers previously had to maintain — the browser owns the snapshot lifecycle, the compositor thread handles the interpolation, and your code only needs to describe what changes and how fast.
Concept Definition
The View Transitions API solves a specific rendering problem: making the visual gap between two DOM states disappear without forcing layout-thrashing JavaScript. When document.startViewTransition(callback) fires, the browser freezes painting, lifts the current frame into a ::view-transition-old pseudo-element, runs the callback to commit the new state, paints the new frame into ::view-transition-new, and then lets the compositor interpolate between the two snapshots. Your CSS only needs to describe the animation on those pseudo-elements — no JavaScript FLIP maths required.
Execution Model
The transition spans two rendering phases. The snapshot capture phase runs on the main thread — the browser calls getComputedStyle on every element that carries a view-transition-name, takes a bitmap of the viewport, and locks paint. This phase is synchronous; any layout thrashing or long-running synchronous code inside the callback stalls it directly, which is why the callback must remain lightweight.
The animation phase runs on the compositor thread once both snapshots exist. The browser promotes each named snapshot pair to its own compositor layer, animating transform and opacity to move from “before” to “after”. Because the compositor owns this phase, it does not block the main thread and cannot be interrupted by JavaScript garbage collection. The ready promise resolves when the compositor phase starts; finished resolves when all animations complete.
If the callback returns a Promise, the browser waits for that promise to resolve before capturing the “after” snapshot. This is the correct pattern for async data fetches — fetch first, then hand the resolved data to startViewTransition, so the snapshot is never captured against a loading state.
API Reference Table
| API / Property | Values / Syntax | Compositing Tier | Notes |
|---|---|---|---|
document.startViewTransition(cb) |
sync fn or fn returning Promise | main-thread → composite | Returns a ViewTransition object |
ViewTransition.ready |
Promise | — | Resolves when compositor animation starts |
ViewTransition.finished |
Promise | — | Resolves when animation completes |
ViewTransition.skipTransition() |
method | — | Instantly skips to end state |
view-transition-name |
<custom-ident> or none |
composite | Must be unique per document at transition time |
view-transition-class |
<custom-ident>… |
composite | Level 2 spec; target groups of named elements |
::view-transition-old(<name>) |
CSS pseudo-element | composite | Bitmap of element before mutation |
::view-transition-new(<name>) |
CSS pseudo-element | composite | Live rendering of element after mutation |
::view-transition-image-pair(<name>) |
CSS pseudo-element | composite | Container for both old and new layers |
Annotated Code Examples
Intent: Initialize a transition with a lightweight callback and handle lifecycle promises.
function navigateWithTransition(targetState) {
// Gate: respect the user's motion preference before any animation runs
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches
|| !document.startViewTransition) {
updateDOM(targetState); // Instant update — no snapshot, no animation
return;
}
// Fetch data BEFORE entering startViewTransition so the callback
// stays synchronous and does not delay "after" snapshot capture.
const transition = document.startViewTransition(() => {
updateDOM(targetState); // Lightweight — only DOM writes here
});
transition.ready.then(() => {
// Compositor animation has started; safe to run follow-on work
});
transition.finished.then(() => {
cleanupTemporaryClasses();
}).catch(() => {
// Transition was interrupted (e.g. skipTransition called) — recover gracefully
});
}
Rendering Impact — main-thread: The callback executes on the main thread. Synchronous layout reads (
getBoundingClientRect,offsetHeight) inside the callback force a full style/layout recalculation before the “after” snapshot, adding measurable jank. Move all layout reads to beforestartViewTransition.
Intent: Override the default crossfade with a slide using compositor-safe properties only.
/* Scoped override for the root transition pair */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.35s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
animation-fill-mode: both;
/* transform and opacity only — keeps animation on the compositor thread */
}
::view-transition-old(root) {
animation-name: vt-slide-out;
}
::view-transition-new(root) {
animation-name: vt-slide-in;
}
@keyframes vt-slide-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(-30%); opacity: 0; }
}
@keyframes vt-slide-in {
from { transform: translateX(30%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* Reduced-motion override: skip animation entirely */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}
Rendering Impact — composite: Animating only
transformandopacityon::view-transition-old/::view-transition-newkeeps every frame on the compositor thread. Introducingwidth,height, ormargininto these keyframes immediately escalates to layout and paint, costing the full 16 ms frame budget per frame.
Intent: Give a card element persistent identity so it morphs in place rather than crossfading.
/* Assign unique identity to the card before the DOM change */
.product-card {
view-transition-name: product-card-detail;
/* 'none' is valid — use it to opt an element out of transition */
}
/* Custom morph: scale up from list thumbnail to detail view */
@keyframes vt-card-expand {
from { transform: scale(0.6); opacity: 0.4; }
to { transform: scale(1); opacity: 1; }
}
::view-transition-new(product-card-detail) {
animation: vt-card-expand 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-new(product-card-detail) {
animation: none;
}
}
Rendering Impact — composite: Each named element becomes its own compositor layer during the transition. Naming too many elements simultaneously (dozens or more) creates proportional GPU memory pressure. Limit
view-transition-nameto the elements whose visual continuity is meaningful to the user.
DevTools Workflow
Auditing view transitions in Chrome DevTools requires three panels used in sequence.
Step 1 — Capture a Performance trace. Open DevTools → Performance tab → click Record → trigger the transition → stop recording. In the flame chart, locate the startViewTransition call in the Main thread track. Measure the gap between the call and when compositor work begins; a long gap here indicates a heavy callback.
Step 2 — Inspect layers. Open the Layers panel (More tools → Layers). After triggering a transition (with DevTools open and “Pause on caught exceptions” active to freeze mid-transition), expand the layer tree. Each view-transition-name element should appear as its own compositor layer prefixed with View Transition. Missing layers mean the element failed to promote — check for will-change: auto conflicts or missing names.
Step 3 — Enable Paint Flashing. Open Rendering tab → enable “Paint Flashing”. Trigger the transition. Green flashes during the animation phase indicate the browser is re-painting within the compositor animation — this should not happen if animations are restricted to transform and opacity. A flash means a non-composite property leaked into the keyframes.
Step 4 — Firefox DevTools. In Firefox, open the Animation inspector (Inspector → Animations). After a transition, the ::view-transition-old and ::view-transition-new pseudo-elements appear in the animation timeline. Use the scrubber to step through the animation and verify timing functions match design intent.
Failure Modes & Fixes
Problem: Transition aborts with DOMException: Duplicate view-transition-name.
Root cause: Two or more elements in the document carry the same view-transition-name value at the moment the snapshot is captured.
Fix: Verify uniqueness programmatically with document.querySelectorAll('[style*="view-transition-name"]') before calling startViewTransition. In list-rendering frameworks, set the name dynamically from a unique item ID and remove it from off-screen items.
Problem: “After” snapshot shows a loading skeleton instead of the finished state.
Root cause: The startViewTransition callback is async and performs a data fetch inside, meaning the DOM update commits against the loading state before data arrives.
Fix: Move the fetch before startViewTransition. Pass the resolved data closure into the callback so it only writes to the DOM — never awaits network inside the snapshot window.
Problem: Fixed header or modal backdrop disappears behind the transition overlay.
Root cause: startViewTransition creates a new stacking context that sits above the document. Elements without an explicit isolation: isolate boundary can be occluded by the generated ::view-transition pseudo-element tree.
Fix: Apply isolation: isolate to persistent UI elements (headers, modals, toasts) that must stay above the transition layer. Alternatively, assign them a view-transition-name so they are included in the snapshot contract and remain visible throughout.
Problem: The transition flickers on the first invocation only.
Root cause: The browser promotes named elements to compositor layers lazily. On the first transition, promotion happens mid-snapshot, causing a brief unpainted frame.
Fix: Apply will-change: transform to elements that will receive view-transition-name, or trigger a silent “warm-up” transition (with skipTransition() called immediately) on page load to force layer promotion before user interaction. See layer promotion and will-change strategy for the full treatment.
Problem: Transition looks wrong in React — new content appears mid-animation.
Root cause: React’s concurrent rendering or batched state updates flush outside the startViewTransition callback window, so the “after” snapshot captures a partially reconciled tree.
Fix: Wrap setState (or the router’s navigate()) directly inside the callback. In React 18+, use startTransition from React inside the View Transitions API callback so React’s scheduler and the browser’s snapshot both see the same committed state.
Accessibility and Reduced Motion
The prefers-reduced-motion: reduce media query is the primary accessibility gate for view transitions. Apply it at two levels:
-
JavaScript gate: Check
window.matchMedia('(prefers-reduced-motion: reduce)').matchesbefore callingstartViewTransition. If the preference is set, call your DOM-update function directly — no API invocation, no snapshot, no animation. -
CSS override: Always include a
@media (prefers-reduced-motion: reduce)block that setsanimation: noneon both::view-transition-oldand::view-transition-new. This handles cases where the JavaScript gate is bypassed (e.g., server-side navigation using the Navigation API) and provides a declarative fallback that works independent of script execution order.
For users with vestibular disorders, large viewport-spanning slides or zooms are the most problematic motion patterns. If you cannot eliminate the transition, consider replacing the directional slide with a simple opacity crossfade inside the reduced-motion block — keeping the state-change signal while removing the spatial movement that causes discomfort. This pairs naturally with the CSS @starting-style entry effects pattern, which provides accessible element-entry animation without spatial translation.
Frequently Asked Questions
Does the View Transitions API work across different browser tabs or windows?
No. The API operates exclusively within the same browsing context and document lifecycle. Cross-tab synchronization requires BroadcastChannel or SharedWorker coordination, which falls outside the native transition scope.
How do I handle prefers-reduced-motion with view transitions?
Wrap the document.startViewTransition call in a window.matchMedia('(prefers-reduced-motion: reduce)') check. If the preference is set, execute the DOM update synchronously without invoking the API. Also add a CSS @media (prefers-reduced-motion: reduce) block targeting the pseudo-elements as a declarative backup.
Can I animate layout properties like width or margin during a transition?
Yes, but it triggers synchronous layout recalculations on the main thread every frame. Restrict animations to transform and opacity to keep rendering on the compositor thread. When a layout shift is genuinely required (e.g. a card expanding), use view-transition-name on that specific element and animate its dimensions inside the pseudo-element keyframes — isolated layout cost is far cheaper than full-document layout on every frame.
What happens when two elements share the same view-transition-name?
The browser throws a DOMException and aborts the entire transition, leaving the DOM in the post-callback state without any animation. Every name must be unique within the document at the instant of snapshot capture.
Can I chain multiple view transitions back-to-back?
Starting a new startViewTransition while one is still running interrupts the current transition, skips it to its end state, and immediately begins the new one. If uninterrupted sequential animations matter, await the finished promise before starting the next transition.
Related
- Implementing cross-document view transitions in SPAs — router synchronization and Navigation API integration
- CSS
@starting-styleand entry effects — element-entry animation that pairs with view transitions - Scroll-driven animation patterns — scroll-timeline-based motion, contrasting with snapshot-based transitions
- Compositor-only property optimization — which CSS properties stay off the main thread
- Modern View Transitions & Scroll APIs — parent section covering the full browser-native motion API surface