The View Transitions API Explained: Morphing Animations Between Page States

The View Transitions API morphs between DOM states using CSS ::view-transition pseudo-elements, but requires JavaScript to trigger—it is not purely declarative.

The View Transitions API morphing animation is not a CSS feature, and the sooner you stop treating it as one, the sooner it stops breaking your builds. The trigger is a JavaScript method, document.startViewTransition(). The entire declarative part, the pseudo-elements, the snapshot pairing, the duration choices, sits in CSS that only exists while that method runs. If you have been waiting for a pure-CSS way to morph a card into a detail view, you have been waiting for a feature that does not exist. What shipped instead, in every engine, is a two-language contract: JavaScript captures the old and new DOM states, CSS animates between the two captured images, and the compositor does the heavy lifting. This guide covers the contract, the exact code for three common morphs, the failure modes that waste your afternoon, and the one interop gap you need to check before you ship a cross-document transition.

The Trigger Is JavaScript, the Animation Is CSS

Why CSS Alone Cannot Start A Transition

The API is unambiguous: document.startViewTransition(callback) is the only way to start a morph. The callback is a function that updates the DOM, either synchronously or by returning a promise that resolves after the update. The browser takes a capture of the current page, runs your callback, takes a capture of the new page, and then creates a pseudo-element tree that layers the old image over the new one. Without that JavaScript call, the DOM update happens instantly. That is the same behaviour the user got before the API existed. No CSS rule can invoke a transition, because CSS has no way to say “run this DOM mutation now.”

The CSS side is purely the animation definition. Once the transition starts, the browser builds a tree of ::view-transition pseudo-elements, each named by a view-transition-name property you set on the elements you care about. The default, when you set nothing, is a single root transition that cross-fades the entire page. Useful but uninspired. The interesting work, the morphing, requires you to tag elements with view-transition-name and then write rules for the generated pseudo-elements that mirror them.

Minimal Card-To-Detail Morph

Here is the minimal single-page card-to-detail transition. It replaces a JavaScript library that manually animates width, height, and position between two elements. That classic “animate from here to there” problem used to require measuring getBoundingClientRect on both nodes and then running a transform animation with requestAnimationFrame. The view transition does all of that measurement and capture for you.

<article class="card" id="card-1">
  <h2>Mountain Hut</h2>
  <p>Short description of the hut.</p>
  <button onclick="expandCard(1)">View details</button>
</article>
<div id="detail-1" class="detail" hidden>
  <h2>Mountain Hut</h2>
  <p>Full description, photos, booking link.</p>
  <button onclick="collapseDetail(1)">Back</button>
</div>
.card {
  view-transition-name: hut-card;
}
.detail {
  view-transition-name: hut-detail;
}
function expandCard(id) {
  const detail = document.getElementById(`detail-${id}`);
  if (!document.startViewTransition) {
    detail.hidden = false;
    return;
  }
  const transition = document.startViewTransition(() => {
    document.getElementById(`card-${id}`).hidden = true;
    detail.hidden = false;
  });
  transition.ready.then(() => {
    // Optional: add a class to the root to trigger a custom animation.
  });
}

The browser captures the card, reveals the detail, and then morphs the card’s image into the detail’s image. The view-transition-name values must be unique across all rendered elements at the moment of capture. Two elements sharing a name will cause the transition to fail silently. No animation, no error in the console, just a hard cut. That is common mistake one, and it costs you an hour of debugging every time.

Common mistake two is subtler: the callback must actually change the DOM. If you write document.startViewTransition(() => { detail.hidden = false; }) but the detail was already visible, the old and new states are identical. No visual transition occurs. The callback runs synchronously or returns a promise; if it returns nothing and the DOM update is asynchronous, the browser captures the same state twice and you get a cross-fade that looks like a flash.

document.startViewTransition() and the ViewTransition Object

The Three Promises You Use In Practice

The method returns a ViewTransition object with three members you will use. ViewTransition.ready is a promise that resolves when the pseudo-element tree is created and the animation is about to start. You use it to hook into the beginning, say, to add a class that changes a duration. ViewTransition.finished resolves when the animation is complete and the new page view is visible and interactive. You use it to clean up any temporary state. ViewTransition.skipTransition() aborts the animation part, leaving the new page in place.

A common pattern is to guard against unsupported engines. The feature reached Baseline newly available for single-page transitions, according to the MDN Browser Compatibility Data and the Web Platform Status dashboard. That means you can use it without a flag in Chrome, Edge, Firefox, and Safari. But the guard is still worth writing. The fallback is trivial and the cost of a broken transition is a page that refuses to update. The code below checks for support and falls back to an instant cut, which is exactly what the user would have seen before the API existed.

if (!document.startViewTransition) {
  // Fallback: update DOM immediately, no animation.
  detail.hidden = false;
  card.hidden = true;
} else {
  const transition = document.startViewTransition(() => {
    detail.hidden = false;
    card.hidden = true;
  });
  transition.finished.catch(() => {
    // Transition was skipped or failed; DOM is already updated.
  });
}

Notice the catch on transition.finished. A transition can be skipped programmatically or by the engine (for example, if the user navigates away), and the promise rejects. Without the catch, you get an unhandled promise rejection in the console. The DOM is already in the new state, so the page is correct. The catch prevents a noisy error.

::view-transition Pseudo-Elements and the Capture Tree

The Pseudo-Element Hierarchy

When a transition runs, the browser creates a root pseudo-element, ::view-transition, and under it a set of groups, each named by a view-transition-name. The structure is ::view-transition-group(<name>) containing ::view-transition-image-pair(<name>), which contains ::view-transition-old(<name>) and ::view-transition-new(<name>). The old pseudo-element holds the before capture, the new holds the after capture. The image-pair is a compositing layer that lets the old and new fade independently.

You write CSS against these pseudo-elements to control the animation. The simplest rule is to set animation-duration on a group to speed up or slow down the morph for that element alone. This is where the declarative power lives: the browser computes the transform from old to new automatically, using the layout positions and sizes captured at each moment. You do not need to know the starting or ending coordinates. You say “animate this group over a specific duration” and the compositor interpolates the transform and opacity.

List Reordering Without FLIP

The list reordering sample below replaces a FLIP animation technique, First, Last, Invert, Play, which required you to measure positions, invert deltas, and then play a transform animation while hoping no layout shift happened mid-frame. The view transition does the measurement for you on the compositor, so the animation runs smoothly even on low-end devices as long as you animate only transform and opacity.

<ul id="list">
  <li class="item" style="view-transition-name: item-1">Alpha</li>
  <li class="item" style="view-transition-name: item-2">Beta</li>
  <li class="item" style="view-transition-name: item-3">Gamma</li>
</ul>
<button onclick="reorder()">Shuffle</button>
function reorder() {
  const list = document.getElementById('list');
  const items = Array.from(list.children);
  items.sort(() => Math.random() - 0.5);
  const transition = document.startViewTransition(() => {
    list.append(...items);
  });
  transition.ready.then(() => {
    // Animation is about to start.
  });
}
::view-transition-group(item-1) {
  animation-duration: 0.3s;
}
::view-transition-group(item-2) {
  animation-duration: 0.45s;
}
::view-transition-group(item-3) {
  animation-duration: 0.6s;
}

The different durations create a staggered, organic feel. Without them, all items would move at the same speed, which reads as mechanical. The ability to target individual groups by name is the whole point of the view-transition-name property: it gives you per-element control over the animation, not just a global cross-fade.

CSS View Transitions Cross-Document and the Interop Gap

Multi-Page Morphs Are Chromium-Only

The single-page transition is Baseline newly available, but cross-document view transitions are not. A cross-document transition, where a navigation from one HTML document to another triggers a morph instead of a reload, requires the @view-transition at-rule and a navigator API hook. The at-rule uses a types descriptor that filters which navigations get a transition. As of 2026, cross-document view transitions are limited to Chromium, and the Interop 2025 dashboard tracks failing Web Platform Tests (WPT) subtests for the feature. The gap is real: Firefox and Safari have not shipped the cross-document path, so a link click from page A to page B will not morph unless the user is on Chromium.

If you are building a multi-page site, the accepted fallback is an @supports selector check. The @supports rule in CSS can test whether the ::view-transition pseudo-element is supported, and you gate your cross-document transition styles behind that. If the pseudo-element is not supported, the page falls back to a normal navigation. That is the same behaviour the user got before the API existed. The code below guards against writing cross-document transition styles without a check, which would apply in engines that ignore unknown at-rules and leave the page in a broken, half-animated state.

@supports selector(::view-transition) {
  @view-transition {
    navigation: auto;
  }
  ::view-transition-old(root) {
    animation-duration: 0.25s;
  }
  ::view-transition-new(root) {
    animation-duration: 0.25s;
  }
}

The navigation descriptor is part of the @view-transition at-rule, and it takes a value of auto or none. Auto enables transitions for same-origin navigations that meet the criteria; none disables them. The types descriptor, when you need it, lets you filter by navigation type: push, replace, or reload. But do not rely on it yet. The Interop gap means your cross-document transition will work on Chromium and silently not work elsewhere. The @supports guard at least ensures the fallback is a clean navigation, not a broken style application.

View Transition Accessibility and prefers-reduced-motion

Respecting The User's Motion Preference

The WCAG 2.2 guidelines and the legal frameworks that adopt them, Section 508 in the United States, EN 301 549 in the European Union, AODA in Ontario, Canada, all require that users who request reduced motion get it. The View Transitions API gives you no automatic exemption. A morphing animation is a motion effect, and if you do not check prefers-reduced-motion, you are forcing vestibular-triggering movement on users who have explicitly asked for none.

The override is a single CSS block. You set the animation-duration to 0 and, crucially, you also skip the transition itself in JavaScript. The CSS override alone is not enough. The pseudo-element tree still exists, and the browser still captures images, which can cause a flash. The correct pattern is to check the media query in JavaScript and call skipTransition() or not start a transition at all.

function expandCard(id) {
  const detail = document.getElementById(`detail-${id}`);
  const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (prefersReduced || !document.startViewTransition) {
    detail.hidden = false;
    return;
  }
  const transition = document.startViewTransition(() => {
    document.getElementById(`card-${id}`).hidden = true;
    detail.hidden = false;
  });
}
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*) {
    animation-duration: 0s !important;
    animation-timing-function: step-end !important;
  }
}

The JavaScript check is the primary guard. The CSS block is a secondary safety net for cases where the transition starts from somewhere else, such as an engine-initiated navigation in the cross-document path. The !important is justified here because the reduced-motion preference must win over any other animation-duration you set. Specificity alone cannot guarantee that when a user has a custom stylesheet or an extension forcing motion reduction.

A transition that runs with animation-duration: 0s still captures images and still runs the pseudo-element lifecycle. It is not free. The JavaScript check avoids that cost entirely by skipping the startViewTransition call. The fallback, when you skip, is an instant cut: the DOM updates, the page is correct, and the user sees no movement. That is the same behaviour they would get on an engine without support. It is the correct accessibility behaviour.

What the CSS Animation Distinction Means for Performance

Compositor Work Versus Capture Cost

The phrase “CSS animation” is overloaded. A CSS transition on transform is compositor-only and runs off the main thread. A view transition is also compositor-only for the morph itself, but the capture has a cost: the browser must paint the old and new states into images. Paint cost is qualitative, none, low, medium, high, and for a page with a complex DOM, the capture can add a visible hitch before the animation starts. Layout cost follows the same qualitative scale, and any property you animate that is not transform or opacity triggers layout or paint, which moves the animation to the main thread and destroys the compositor advantage.

This is the real gap in the “use CSS for all animations” advice. CSS cannot animate to auto, cannot sequence complex timelines without the Web Animations API, and cannot respond to JavaScript state without flipping custom properties. The View Transitions API closes part of that gap for state changes, but it does not replace CSS Animations for keyframed effects that do not involve a DOM state change. A view transition is a state-change morph. A CSS Animation is a keyframed performance. They are different tools, and treating them as interchangeable writes animations that feel wrong.

The practical takeaway: keep the transitioned elements isolated with contain: paint to reduce the capture cost. The contain property tells the browser that this element’s paint is confined to its bounds, which lets the compositor cache the image more aggressively. It is not required. On a page with large images or complex backgrounds, it is the difference between a smooth morph and a visible stutter.

The Failure Case: When the Transition Flickers or Never Fires

Flickering And Unstable Captures

The most common failure is flickering. The before and after states are not stable during the capture: if the layout shifts while startViewTransition is capturing the old state, the captured image is wrong, and the morph looks like a glitch. This happens when the callback modifies multiple elements and the engine interleaves layout with capture. The fix is to batch all DOM changes inside the callback and not read layout properties (getBoundingClientRect, offsetWidth, etc.) between the capture and the callback. Reading layout forces a synchronous reflow, which can invalidate the image.

Silent Failures And Missing Groups

The second failure is a transition that never fires. Check three things: the view-transition-name values are unique; the callback actually changes the DOM; and the elements you tagged are rendered, not hidden with display: none, at the moment of capture. An element with display: none has no box, so the browser cannot capture it, and the group for that name is not created. Use the hidden attribute or visibility: hidden instead of display: none if you need the element to participate in the morph.

The third failure is the silent skip. If the engine decides the transition cannot run, for example because the user has a forced reduced-motion setting at the OS level, it skips the animation and updates the DOM instantly. Your transition.finished promise rejects, and if you did not attach a catch, the console shows an unhandled rejection. Attach the catch. It is one line and it saves you an hour of “why is my animation not working” debugging.

The fourth failure is the cross-document gap. You ship a beautiful morph between pages, test it in Chrome, and then a Firefox user reports a hard reload. That is not a bug in your code. It is the Interop gap. The @supports selector guard is the honest answer, and the fallback, no animation, instant navigation, is acceptable. The user gets the same experience they had before the API existed, which is the baseline you are always allowed to return to.

Where the CSS Responsibility Ends and the JavaScript Requirement Begins

The Division Of Labour

The division of labour is the crux of the whole feature. JavaScript owns the lifecycle: it captures the old state, runs the DOM update, and exposes the ready and finished promises. CSS owns the presentation: it names the elements with view-transition-name, styles the ::view-transition pseudo-elements with animation-duration and other animation properties, and respects prefers-reduced-motion. Neither side works alone. Claiming that view transitions are a CSS feature is a category error that leads to broken implementations.

The CSS part is declarative. You write rules and the browser figures out the intermediate frames. The view-transition-name property is a custom-ident, and its initial value is none. The @view-transition at-rule, for the cross-document path, uses the navigation and types descriptors, both of which have the values auto/none and none/<custom-ident>+, respectively. None of these are procedural. They do not say “move this element a specific number of pixels to the right.” They say “pair the old capture of this element with the new capture, and animate the transform between them.” The compositor computes the actual transform. That is why the feature is both fast and resilient to layout changes.

The JavaScript requirement is the part that trips up developers who come from a pure-CSS background. You must call document.startViewTransition() inside the event handler that triggers the state change. You cannot attach a view transition to a CSS :hover or a class change. The API has no CSS trigger. If you are using a framework like React or Vue, the callback is where you call your state update and let the framework mutate the DOM. The browser then captures the new state after the callback resolves, including any microtasks or promises it returns.

This distinction, JavaScript triggers, CSS animates, is the sentence every developer needs to internalise. It is the difference between a feature that works and a feature that silently does nothing. And it is the reason this guide exists: to correct the claim that view transitions are purely a CSS feature.

The One Action to Take Next

Open your project. Find the one place where you currently animate a width or height change between two states, and replace it with a view transition. Start with the simplest case: a card expanding to a detail view. Add view-transition-name to both elements, wrap the DOM update in document.startViewTransition(), and check the result in your browser. If it works, add the prefers-reduced-motion guard. If it flickers, add contain: paint to the transitioned elements. Do not attempt the cross-document transition yet unless your entire audience is on Chromium. The Interop gap is real, and the fallback is fine. The single-page version is Baseline newly available, it is safe to ship today, and it replaces a JavaScript library you probably should not be maintaining anyway.

Frequently Asked Questions

Does the View Transitions API work without JavaScript? No. The trigger is document.startViewTransition(), and without it, the DOM update happens instantly. CSS alone cannot start a transition.

Is the View Transitions API the same as a CSS transition? No. A CSS transition animates a property change on an existing element. A view transition captures before and after images of the entire page or named elements and morphs between them. They are different mechanisms with different performance profiles.

Can I use view transitions with a multi-page site? Yes, but only on Chromium as of 2026. The @view-transition at-rule and the navigation descriptor are limited to Chromium, and the Interop dashboard tracks failing WPT subtests for the feature. Use the @supports selector guard to fall back to normal navigation.

What is the fallback when document.startViewTransition is not supported? The DOM update happens instantly, with no animation. That is the same behaviour the user got before the API existed. The guard is a simple if (!document.startViewTransition) check.

How do I disable the animation for users who prefer reduced motion? Check window.matchMedia('(prefers-reduced-motion: reduce)').matches in JavaScript and skip the transition. Also add a CSS media query that sets animation-duration to 0 on all ::view-transition-group elements as a secondary safety net.

Related Topics

CSS Grid Layout is a two-dimensional layout system with explicit row and column tracks. View transitions can animate a grid item’s position when the grid changes, but the transition itself does not care about the layout system.

text-wrap: balance is an engine-native way to avoid widows and ragged edges in headings without JavaScript polyfills. It has nothing to do with view transitions, but it is another example of a CSS feature that reduces the need for manual DOM manipulation.

Baseline is a Web Platform status grouping that tells you whether a feature is newly available or widely available across engines. The View Transitions API single-page path is newly available; the cross-document path is limited.

Interop is the annual cross-browser initiative where vendors agree to fix the same conformance gaps. The 2025 project includes cross-document view transitions, and the dashboard tracks the failing WPT subtests.

Scroll-Driven Animations use scroll() and view() timeline functions to progress animations based on scroll position. They are compositor-friendly and can coexist with view transitions, but they are a separate feature.

CSS Houdini exposes the engine’s layout, paint, and parser engines to JavaScript. The Layout and Paint APIs remain Chromium-only, which is a different interop gap from view transitions.