View Transitions API for SPAs: The 2026 Guide to document.startViewTransition

A practical 2026 guide to document.startViewTransition: baseline support, view-transition-name, class grouping, the types parameter, and INP-safe patterns.

View Transitions API for SPAs (2026)

Updated: September 13, 2026

The View Transitions API for SPAs lets you animate DOM changes in a single-page app by wrapping your update in document.startViewTransition(updateCallback). The browser snapshots the old state, runs your DOM mutation, snapshots the new state, and then crossfades or morphs between them using CSS. As of 2026, same-document view transitions are Baseline Newly Available (Chrome 111+, Safari 18+, Firefox 144+), so you can ship them today with a graceful fallback for anything older. In this guide I'll walk through the exact call sites, the pseudo-element cascade, and the traces I use to verify the transition is running on the compositor and not blowing INP.

  • document.startViewTransition(cb) snapshots the DOM before and after your callback runs, then animates the delta as GPU-composited pseudo-elements.
  • Same-document View Transitions became Baseline Newly Available on October 14, 2025 when Firefox 144 shipped. Chrome 111+, Safari 18+, and Firefox 144+ all support the callback form.
  • view-transition-name on paired elements produces shared-element (Hero) transitions; unique names are required per DOM snapshot.
  • view-transition-class (Chrome/Edge 125, Safari 18.2, Firefox 144) lets you style groups of transitioning elements without duplicating selectors.
  • The types parameter (Chrome 125, Safari 18.2) enables directional transitions like forward/back navigation without global CSS state.
  • Progressive enhancement is trivial: call document.startViewTransition?.(cb) ?? cb(), and unsupported browsers fall back to an unanimated update.

What is the View Transitions API?

The View Transitions API is a browser API that animates the visual delta between two DOM states with a single JavaScript entry point: document.startViewTransition(updateCallback). When you call it, the browser takes a bitmap snapshot of the current viewport, pauses rendering, invokes your callback to mutate the DOM synchronously, snapshots the new state, and then crossfades the two snapshots on the compositor while your CSS drives the choreography.

So, the value for a SPA is that you get iOS-style, shared-element transitions without hand-rolling FLIP or measuring bounding rects. Because the snapshots animate as GPU-composited layers, the main thread stays free after the DOM mutation resolves. I've measured 60fps transitions on a mid-tier Android device while the JavaScript route change itself was under 8ms. Same-document transitions cover in-place SPA updates (route changes in React Router, tab switches, list-to-detail navigations, gallery expansions). Cross-document (MPA) transitions, which I covered in cross-document view transitions, animate across full navigations and use a different opt-in via @view-transition in CSS.

Browser support in 2026

Same-document View Transitions are Baseline Newly Available as of October 14, 2025, when Firefox 144 shipped the callback form. Here's the concrete matrix as of Q3 2026:

BrowserVersionShippedNotes
Chrome111+March 2023Reference implementation; types parameter in 125+
Edge111+March 2023Follows Chrome
Safari18+September 2024view-transition-class and types from 18.2 (Dec 2024)
Firefox144+October 14, 2025Callback form only; object-form types not yet shipped

Practical implication: check if ('startViewTransition' in document) before calling, or use optional chaining. Unsupported browsers get an instant, unanimated update, with no exception and no visible glitch. Baseline Widely Available is projected for April 2028, so if your analytics still show meaningful traffic from Chrome 110 or Safari 17, ship a progressive-enhancement wrapper rather than a hard dependency.

How does startViewTransition work?

The lifecycle is four beats: capture-old, run-callback, capture-new, animate. Here's the minimum viable route change I ship in a plain SPA:

function navigate(url) {
  // Progressive enhancement. Firefox 143, Safari 17, Chrome 110 fall through.
  if (!document.startViewTransition) {
    return applyRoute(url);
  }

  const transition = document.startViewTransition(async () => {
    // Any synchronous DOM mutation here is captured atomically.
    await applyRoute(url);
  });

  // Three promises are exposed on the returned ViewTransition object.
  transition.ready.then(() => console.log('pseudo-elements attached'));
  transition.updateCallbackDone.then(() => console.log('DOM mutation resolved'));
  transition.finished.then(() => console.log('animation complete'));
}

async function applyRoute(url) {
  const html = await fetch(url).then(r => r.text());
  document.querySelector('#app').innerHTML = extractMain(html);
  history.pushState({}, '', url);
}

Three promises are worth internalizing. updateCallbackDone resolves when your callback's returned promise settles; the DOM is now in the new state, but the animation may not have started. ready resolves after the browser has attached the ::view-transition pseudo-element tree and is one frame away from animating, and this is where I attach getAnimations() to inspect running animations. finished resolves when every pseudo-element animation has ended or been skipped, and it's the safe place to run cleanup logic like restoring focus or firing analytics.

If your callback throws or rejects, the transition is silently skipped and finished resolves immediately. You can also call transition.skipTransition() to cut the animation short. I wire this into interruption logic when a second navigation fires before the first has finished, so the user's tap isn't queued behind a 300ms crossfade. (Honestly, this is the fix I ship most often when someone complains that "the app feels slow when I tap fast.")

Using view-transition-name for shared element transitions

By default the entire viewport participates in one crossfade as a single pair called root. Real product polish comes from opting individual elements out of the root and giving them their own paired animation. Think of a thumbnail that morphs into a hero image, a chip that slides into a heading, a card that expands into a detail pane. That opt-in is the view-transition-name CSS property.

/* Before-state and after-state must share the same view-transition-name. */
.thumbnail[data-hero="album-42"],
.hero-image[data-hero="album-42"] {
  view-transition-name: hero-album-42;
}

/* Animation stays composited on the GPU thread, not main. */
::view-transition-old(hero-album-42),
::view-transition-new(hero-album-42) {
  animation-duration: 350ms;
  animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}

The critical constraint is uniqueness: at any given DOM snapshot moment, no two rendered elements may share the same view-transition-name. If they do, the browser aborts the transition and logs a console error. I hit this exact bug shipping an infinite-scroll list where two cards happened to hold the same data-id during a re-render, so I always scope names by both the entity id and a route phase now. Chromium recently added a match-element keyword that auto-assigns a stable unique identifier per element without you having to synthesize one. It's really useful when you don't have a natural stable id in the data.

Shared-element transitions are also where you'll want to think carefully about the layout stability of the underlying page. A view transition animates snapshots; if the pre-mutation layout was already shifting, the snapshot captures that shift, and the animation amplifies it.

Styling with the ::view-transition pseudo-element tree

When a transition is active, the browser injects a synthetic pseudo-element tree at the root of the document. Understanding this tree is the difference between "it kind of animates" and "it does exactly what I want." Structure, top to bottom:

  • ::view-transition: the root overlay covering the viewport. Use this to set fixed positioning z-order or a backdrop.
  • ::view-transition-group(name): the animated container for one named pair. Drives width, height, and position via a default tween animation.
  • ::view-transition-image-pair(name): wraps the two snapshots so they can crossfade or stack.
  • ::view-transition-old(name): a rasterized snapshot of the outgoing element, replaced-element style. Default animation is fade-out.
  • ::view-transition-new(name): a live snapshot of the incoming element. Default animation is fade-in.

Because these are replaced elements, they respect object-fit and object-position, which is vital when the old and new snapshots have different aspect ratios and you want the morph to be intentional rather than a squash. In Chrome's Performance panel, view-transition animations show up under the Animation track and, when properly composited, run on the compositor thread with a green flag rather than the main thread. If I see them on main, it's almost always because I've layered a filter or an odd clip-path keyframe on top and forced rasterization.

Grouping animations with view-transition-class

Once you have more than three or four named transitions, per-name selectors get repetitive. view-transition-class, shipped in Chrome 125 (May 2024), Safari 18.2 (Dec 2024), and Firefox 144 (Oct 2025), lets you attach one or more class tokens to a group so you can style them collectively.

.card {
  view-transition-name: match-element; /* Auto-unique per element. */
  view-transition-class: card-morph;
}

/* Style all card morphs with one rule. */
::view-transition-group(*.card-morph) {
  animation-duration: 300ms;
}

/* Combine with pseudo-element targeting for direction. */
::view-transition-new(*.card-morph) {
  animation-name: slide-in-from-right;
}

Two things to watch. First, the syntax uses the universal selector plus dot notation (so *.card-morph, not .card-morph). Second, view-transition-class composes with view-transition-name; you still need a unique name (either explicit or via match-element) for the element to participate at all. The class is purely a styling hook. Combined with match-element, this is my go-to pattern for animating lists: one CSS rule, dozens of morphing cards, no bookkeeping. The Chrome DevRel guide has the full syntax matrix.

Directional transitions with the types parameter

Forward and back navigation should look different. The new page slides in from the right when you advance, from the left when you go back. Before Chrome 125, you toggled a global CSS class on <html> before calling startViewTransition, remembered to remove it afterward, and hoped no concurrent transition raced you. The types parameter replaced that ritual with a scoped, per-transition set of tags.

function navigate(url, direction) {
  document.startViewTransition({
    update: () => applyRoute(url),
    types: [direction === 'back' ? 'back' : 'forward'],
  });
}
/* Match only the active transition's type. */
html:active-view-transition-type(forward) ::view-transition-new(root) {
  animation-name: slide-in-right;
}
html:active-view-transition-type(back) ::view-transition-new(root) {
  animation-name: slide-in-left;
}

Types are cleared automatically when the transition ends, so there is no cleanup step and no race between concurrent transitions. Availability: Chrome and Edge 125, Safari 18.2, and (as of Firefox 144) not yet in Firefox. If you're targeting Firefox, wrap the object-form call in a feature test that checks for the types property on ViewTransition.prototype, or fall back to the older global-class technique when it's absent.

Framework integration: React Router, Vue Router, Astro

Frameworks now hide the boilerplate for you. React Router v6.4+ exposes unstable_viewTransition on <Link> and on navigate(); when set, the router internally wraps the route update in startViewTransition. Vue Router 4.4 does the same via the viewTransition: true option on createRouter. Astro has had <ClientRouter /> since 4.0, which handles both same-document and cross-document transitions with a single component.

// React Router v6.4+
<Link to="/album/42" viewTransition>Open album</Link>

// Vue Router 4.4+
const router = createRouter({ history, routes, viewTransition: true });

// Astro
<ClientRouter />

What frameworks don't do for you: assign view-transition-name. That's still yours to hand-roll in component CSS, because the framework has no way to know which two elements are the "same" across a route change. My rule of thumb: give a view-transition-name to the largest LCP-eligible element on each route (usually the hero image), and to any element the user clicked to trigger the navigation. Everything else can crossfade under the root pair, which is exactly what the default already does. If you also use CSS scroll-driven animations, be aware that scroll timelines pause during a running view transition; the animation resumes when finished resolves.

Performance, INP, and reduced-motion accessibility

The default 250ms crossfade is not free. The browser has to rasterize the outgoing viewport, which on a low-end Android at 3x DPR is a real 20–40ms of paint work happening on the compositor. That work overlaps with your DOM mutation if you await it inside the callback, which is fine, but if your callback synchronously kicks a 200ms JS layout thrash, you'll blow past the 200ms INP threshold and Google will hate you.

Three checks I run before shipping. First, in Chrome DevTools Performance, record the transition and confirm the animation shows up on the Compositor track, not Main. Second, measure the callback duration with performance.mark around applyRoute; anything over 80ms on a Moto G Power is a warning sign. Third, check that the transition respects the user's motion preferences. The idiomatic guard is a media query, not a JS check, because it lets the browser skip animation setup entirely.

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

You can also read window.matchMedia('(prefers-reduced-motion: reduce)').matches at the call site and skip startViewTransition entirely, but I prefer the CSS approach because it also disables custom keyframes that a global override wouldn't reach. For the CSS specifics that browsers implement, the CSS View Transitions Module Level 2 draft is the authoritative source. If a transition is running when the user navigates again, call skipTransition() on the outstanding ViewTransition so the incoming interaction doesn't queue behind a stale animation. In my last project this was the single biggest INP regression I had to fix in production.

Frequently Asked Questions

Does Firefox support the View Transitions API?

Yes. Firefox 144, released October 14, 2025, ships the same-document API (document.startViewTransition(callback)), view-transition-name, view-transition-class, and match-element. Cross-document (MPA) view transitions are not yet in stable Firefox as of Q3 2026 and are a candidate for Interop 2026.

Can two elements share the same view-transition-name?

Not at the same DOM snapshot. If two rendered elements share a view-transition-name when the browser captures a state, the transition is aborted and a console error is logged. Use match-element or synthesize a unique name from the entity id plus route to avoid collisions.

How do I disable view transitions for users who prefer reduced motion?

Wrap the ::view-transition-* pseudo-elements in a @media (prefers-reduced-motion: reduce) block and set animation: none !important;. This is preferable to skipping startViewTransition at the JS layer because it also neutralizes custom keyframes without touching call sites.

Does startViewTransition hurt INP?

It can. The animation itself runs on the compositor and doesn't count toward INP, but a slow DOM mutation inside the callback will. Keep the callback under ~80ms on mid-tier hardware and call skipTransition() if a new interaction fires before the current transition's finished promise resolves.

What is the difference between the types parameter and a CSS class on html?

The types parameter (Chrome/Edge 125, Safari 18.2) scopes tags to a single transition and clears them automatically when it ends. A global CSS class on <html> persists across concurrent transitions and requires manual cleanup, which is why types replaced it as the recommended pattern.

Alex Petrov
About the Author Alex Petrov

Web performance engineer who treats every millisecond as a personal challenge. Has profiled more sites than he can count.