The View Transitions API

You change a piece of the page — swap a list for a grid, expand a card into a full article, sort a table — and it just pops. One frame it’s the old layout, the next it’s the new one. Users lose the thread: what moved where? Which thumbnail became this hero image?

The classic fix is a small mountain of work. You measure every element’s position before the change, apply the change, measure again, compute the delta, and animate the difference back to zero with a transform. That technique has a name — FLIP (First, Last, Invert, Play) — and every animation library reimplements it. It’s fiddly, it fights the layout engine, and it breaks the moment your markup changes.

The View Transitions API hands that whole job to the browser. You give it a function that mutates the DOM. It photographs the page before and after, and animates from one photo to the other. The default is a clean cross-fade; with a couple of CSS lines, elements you name will smoothly morph from their old position and size to their new one.

The one function you call

Same-document transitions run through a single method:

const transition = document.startViewTransition(updateCallback);

updateCallback is a function that makes your DOM change — set some text, replace a node, toggle a class, re-render your framework’s view. The browser calls it at exactly the right moment, sandwiched between two snapshots.

Here’s the lifecycle in order:

1 · snapshotold state(pixels frozen)2 · callbackyour DOMmutation runs3 · snapshotnew state(pixels frozen)4 · animateold → newcross-fadeYou write step 2. The browser owns 1, 3, and 4.document.startViewTransition(runStep2)
startViewTransition captures the old frame, runs your callback to mutate the DOM, captures the new frame, then animates old to new. Your job is only the middle step.

During the animation the page is visually frozen — what you see is a composited overlay of two snapshots, not the live DOM. That’s the mental model that makes everything else click: you are animating between two photographs, and the real DOM has already finished updating underneath.

A real example

Say clicking a tab swaps the visible panel. Without transitions you’d just replace the content:

function showPanel(id) {
  document.querySelector('.panel.active')?.classList.remove('active');
  document.getElementById(id).classList.add('active');
}

Wrap the mutation and you get an automatic cross-fade — with a fallback for browsers that don’t support it yet:

function showPanel(id) {
  if (!document.startViewTransition) {
    return swap(id);           // no support: just do the update
  }
  document.startViewTransition(() => swap(id));
}

function swap(id) {
  document.querySelector('.panel.active')?.classList.remove('active');
  document.getElementById(id).classList.add('active');
}

That feature check is the whole progressive-enhancement story. Where startViewTransition doesn’t exist, the page updates instantly — exactly what it did before. Nothing breaks.

Here it is running. Click the tabs: the same classList swap you’d write anyway, wrapped in startViewTransition, gives you an automatic cross-fade for free. Open the code and delete the wrapper to feel how abrupt the raw swap is.

interactiveWrap a class swap in startViewTransition

The default: a root cross-fade

If you name nothing, the browser treats the entire page as one snapshot called root and cross-fades it: the old view animates from opacity: 1 to 0, the new view from 0 to 1. The default runs about 0.25s with a smooth ease. That single line of behavior is often all a tab swap or a filter change needs.

But a cross-fade of the whole page is blunt. If only a corner changed, fading the entire viewport feels heavier than the change deserves. The interesting part of this API is telling the browser which pieces should move independently.

Naming elements so they morph

Give an element a view-transition-name and the browser pulls it out of the root snapshot into its own group. If an element with the same name exists in both the old and new states, the browser animates it as one continuous thing — interpolating its position, size, and shape from old to new. This is the shared-element transition, and it’s where the magic lives.

The canonical case: a thumbnail in a grid that expands into a hero image on a detail view. Give both the same name and the small image appears to fly and grow into the big one.

/* grid view */
.thumb[data-id="42"] {
  view-transition-name: product-hero;
}

/* detail view */
.detail-hero {
  view-transition-name: product-hero;
}
old · gridnew · detailview-transition-name: product-hero (same name, both states)
Matching view-transition-name across the old and new DOM makes the browser treat the thumbnail and the hero as the same element, morphing position and size between them instead of cross-fading.

You don’t write any keyframes for that morph. The browser measures where product-hero was, where it lands, and generates the movement. Everything else on the page still cross-fades as root, so the background swaps softly while the hero glides.

Click a swatch below and watch it fly and grow into the detail view, then click Back. The trick is in the JS: the same view-transition-name (hero) is assigned to the small swatch before the snapshot and to the big one inside the callback, so the browser recognizes them as one element and morphs between them. No keyframes anywhere.

interactiveShared-element morph: swatch grows into a hero

For long lists, generating unique names by hand is tedious. Newer engines accept view-transition-name: match-element, which lets the browser mint a stable internal name per element automatically. It’s handy but not everywhere yet — Chrome shipped it in 137; treat it as an enhancement and don’t rely on it as your only path.

The pseudo-element tree

To customize animations you need to know what the browser builds during a transition. When startViewTransition fires, it constructs a tree of pseudo-elements on top of everything, rooted at the <html> element. Each named part (and root) gets a group; each group holds a pair; each pair holds the old snapshot and the new one.

::view-transition…-group(root)…-group(product-hero)…-image-pair(product-hero)…-old(product-hero)static image · fades out…-new(…)live · fades in(same pair/old/newstructure underneath)group animates position + size · image-pair holds both snapshotsold fades out · new fades in · together they read as one moving element
The pseudo-element tree the browser generates during a transition. Each named group contains an image-pair holding the old snapshot (a static image) and the new one (a live rendering). CSS animations on these drive the effect.

Three of these are worth remembering:

  • ::view-transition-group(name) — the container that animates position and size. This is what produces the “fly and grow” morph.
  • ::view-transition-old(name) — a static bitmap of the old state, which fades out by default.
  • ::view-transition-new(name) — a live rendering of the new state, which fades in by default.

You target them with CSS like any pseudo-element. root is the built-in name for everything you didn’t name yourself.

Customizing with CSS

Because the transition is driven by ordinary CSS animations on those pseudo-elements, you override it with ordinary @keyframes. Want the whole page to slide up instead of cross-fade?

@keyframes slide-from-bottom {
  from { transform: translateY(30px); opacity: 0; }
}
@keyframes slide-to-top {
  to { transform: translateY(-30px); opacity: 0; }
}

::view-transition-old(root) {
  animation: 220ms ease both slide-to-top;
}
::view-transition-new(root) {
  animation: 220ms ease both slide-from-bottom;
}

Tweak just the timing of a named morph without rewriting the movement itself:

::view-transition-group(product-hero) {
  animation-duration: 400ms;
  animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}

The demo below replaces the default root cross-fade with a slide. Click Next slide to advance, then open the CSS pane and edit the @keyframes — change translateY to translateX, or push the duration up — and re-run to watch the whole transition change with zero JavaScript edits.

interactiveOverride the root cross-fade with your own @keyframes

Watching the transition from JavaScript

startViewTransition returns a ViewTransition object with three promises and an escape hatch. If you’ve worked with promises, these read naturally:

const t = document.startViewTransition(() => render());

t.updateCallbackDone; // resolves when your callback's promise settles
t.ready;              // resolves when snapshots are taken, animation about to run
t.finished;           // resolves when the animation ends and the real DOM is live
t.skipTransition();   // jump straight to the end state, skipping the animation

The ordering matters, and it’s a common source of confusion. updateCallbackDone fires first (DOM is updated). Then ready (the pseudo-tree exists and animations are queued) — this is where you’d attach a custom JS-driven animation via the Web Animations API. Finally finished.

The order the three promises resolve
1/4
Variables
— nothing yet —
The browser has frozen the old snapshot. Your callback runs and awaits the DOM update.

Because these are real promises, they reject too. If the update callback throws, ready rejects — wrap in try/catch or .catch() if a failed render shouldn’t leave the UI stuck. If you async/await them, remember ready can reject while finished still resolves.

Cross-document transitions for multi-page apps

Everything so far animates within one document. But the same visual continuity is possible across a full page navigation — click a link, the browser loads a new HTML document, and a shared element morphs across the load. No SPA, no router, no JavaScript at all.

You opt in with a CSS at-rule in both the source and destination pages:

@view-transition {
  navigation: auto;
}

With that rule present on both same-origin pages, a normal link click triggers a transition. Elements that share a view-transition-name across the two documents morph between them, exactly like the same-document case. It’s a genuinely striking upgrade for classic server-rendered sites.

/productsdocument A<a href> clickreal navigation/products/42document Bboth pages: @view-transition { navigation: auto } + matching name
Cross-document transitions: two separate HTML documents, connected by @view-transition navigation:auto. A shared view-transition-name lets an element persist visually across the navigation.

Be precise about support here, because it lags the same-document API. Cross-document transitions work in Chrome and Edge 126+ and Safari 18.2+, but Firefox does not support them yet as of July 2026. So this is not Baseline — it’s a progressive enhancement. Pages without support simply navigate normally with no animation, which is a perfectly fine baseline experience.

There’s one more caveat that trips people up. Firefox’s initial 144 implementation shipped the core but not transition types (the types option and the :active-view-transition-type() selector). So if you branch animations on types, guard for it and provide a sensible default. Types are great for “did we go forward or back,” but they’re the least-supported corner of the feature.

Respect prefers-reduced-motion

Motion isn’t free for everyone. Vestibular disorders make sliding, zooming, and morphing genuinely unpleasant or nauseating. Users signal this with the prefers-reduced-motion media query, and view transitions must honor it.

The clean approach: don’t fight the API, just neutralize the animation to an instant swap for those users. Because the default cross-fade is itself a CSS animation, you can cut it to zero duration.

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
  }
}
no-preference~250ms cross-fade / morphreduceinstantsame result, no in-between frames
With reduced motion, the same DOM update happens — but the animation collapses to an instant swap. Users still land on the correct new state; they just skip the movement.

The important insight: you don’t skip the transition for these users, you make it instantaneous. The DOM update is identical. They arrive at the same correct final state — they just don’t watch it move. Killing the animation is an accessibility feature, not a degraded experience.

Gotchas worth internalizing

A few more sharp edges:

  • Only one transition at a time. Start a new startViewTransition while one is running and the browser skips the in-flight one to the end. Don’t fire them on every keystroke of a filter box — debounce.
  • The page is frozen mid-transition. If your callback awaits something slow, users stare at a motionless page. Load data before starting the transition when you can, or keep the awaited work tight.
  • Fixed and sticky elements need care. A position: fixed header captured in the root snapshot can appear to scroll during a transition. Give it its own view-transition-name so it’s treated as an independent, stationary group.
  • view-transition-name establishes a containing block and stacking-ish context. Naming an element can subtly affect layout of absolutely-positioned descendants. Test after adding names.
  • Large snapshots cost memory. Every named element is a separate captured layer. Naming hundreds of things at once is not free — name what actually needs to move.

Summary

  • document.startViewTransition(callback) snapshots the old page, runs your DOM mutation, snapshots the new page, and animates between the two frozen frames. The default is a ~250ms root cross-fade.
  • The callback may be async; the browser waits for its promise before capturing the new state, so you can await a render or a fetch inside it.
  • view-transition-name pulls an element into its own group. A matching name in both states makes the browser morph position and size — the shared-element effect — with no keyframes from you.
  • Customize by targeting the generated pseudo-tree: ::view-transition-group/old/new(name), plus view-transition-class to style many snapshots at once. root is the name for everything unnamed.
  • The returned ViewTransition exposes updateCallbackDone, ready, and finished promises (in that order) and a skipTransition() method.
  • Same-document is Baseline Newly available (Oct 14, 2025) across Chrome/Edge 111+, Safari 18+, Firefox 144+. Cross-document (@view-transition { navigation: auto }) is newer — Chrome/Edge 126+, Safari 18.2+, and not yet in Firefox — so treat it as progressive enhancement, not Baseline.
  • Always honor prefers-reduced-motion by collapsing the animation to an instant swap; the DOM update stays the same.
  • Watch the coexistence, single-transition, and frozen-page gotchas — and feature-detect so unsupported browsers just update instantly.