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.


View Transitions API Lifecycle Five-stage flow diagram showing the browser lifecycle from calling startViewTransition through snapshot capture, DOM mutation, pseudo-element animation, and promise resolution. Call startViewTransition (main thread) Capture "before" snapshot (paint phase) Run callback → mutate DOM (main thread) Capture "after" snapshot (paint phase) ::view-transition-* animate → finished (compositor) Snapshot capture is synchronous — keep the callback lightweight to avoid delaying the "after" snapshot

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 before startViewTransition.


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 transform and opacity on ::view-transition-old/::view-transition-new keeps every frame on the compositor thread. Introducing width, height, or margin into 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-name to 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:

  1. JavaScript gate: Check window.matchMedia('(prefers-reduced-motion: reduce)').matches before calling startViewTransition. If the preference is set, call your DOM-update function directly — no API invocation, no snapshot, no animation.

  2. CSS override: Always include a @media (prefers-reduced-motion: reduce) block that sets animation: none on both ::view-transition-old and ::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.