View Transitions¶
The View Transition API lets the browser animate between two visual states of your app: it snapshots the old state, lets you change the DOM while rendering is paused, snapshots the new state, and then animates between the two using ordinary CSS animations on a tree of pseudo-elements. It works inside a single document (document.startViewTransition(), for single-page apps) and across same-origin navigations (@view-transition { navigation: auto; }, for multi-page apps). For a PWA it is the difference between a web page that blinks from one screen to the next and an app whose screens slide, morph and cross-fade like native UI, without a JavaScript animation library and without keeping both screens in the DOM.
Key takeaways
- Same-document transitions (
document.startViewTransition()) ship in Chrome and Edge 111+, Safari 18+ and Firefox 144+. The options object withtypesneeds Chrome 125, Safari 18.2 or Firefox 147. - Cross-document transitions (
@view-transition,pageswap,pagereveal) ship in Chrome and Edge 126+ and Safari 18.2+. Firefox doesn't support them in a stable release yet, so treat them as progressive enhancement. - A transition is always an enhancement: the update callback runs even when the animation is skipped. Unsupported browsers, hidden documents, duplicate names and timeouts all fall back to an instant DOM update.
- The
ViewTransitionobject exposes three promises:updateCallbackDone(your DOM change finished),ready(pseudo-elements exist, animations are about to start) andfinished(the new state is visible and interactive).readyrejects whenever the animation is skipped, so always handle it. view-transition-namepairs an element in the old state with one in the new state. Names must be unique at capture time.view-transition-classshares one set of styles across many names, and transition types select different animations for different navigations (forward, back, tab switch).- The default animation is a 250 ms cross-fade plus a size and position morph. Rendering is frozen while your update callback runs, so do slow work (network, data parsing) before you start the transition.
- Respect
prefers-reduced-motion. The browser does not reduce view transition animations on its own.
How a view transition works¶
Every view transition, same-document or cross-document, follows the same three-phase model defined in CSS View Transitions Module Level 1 and Level 2:
- Capture the old state. At the next rendering opportunity the browser records every element that has a
view-transition-name(the root element has the namerootby default). For each one it stores an image of the element (including ink overflow such as shadows), its border-box size, its transform relative to the viewport, and a few computed styles (writing-mode,direction,text-orientation,mix-blend-mode,backdrop-filter,color-scheme). - Update the DOM while rendering is suppressed. The browser calls your update callback (or, cross-document, swaps in the new document). The screen keeps showing the last frame of the old state, so users never see an intermediate layout.
- Capture the new state and animate. The browser records the named elements again, builds a pseudo-element tree on top of the page, and runs CSS animations from old to new. When all of them have finished, the pseudo-elements are removed and the real DOM shows through.
sequenceDiagram
participant App as Your code
participant Doc as Document
participant R as Rendering
App->>Doc: "startViewTransition(update)"
Doc->>R: capture old state at next frame
R-->>Doc: snapshots of named elements
Doc->>R: suppress rendering
Doc->>App: call update()
App-->>Doc: promise fulfills (updateCallbackDone)
Doc->>R: capture new state, build pseudo-tree
Doc-->>App: ready resolves
R->>R: run CSS animations
R-->>Doc: all animations finished
Doc-->>App: finished resolves, pseudo-tree removed The pseudo-element tree¶
During the animation phase the browser generates this tree, attached to the root element and painted above everything else on the page, including the top layer (dialogs, popovers):
::view-transition
├─ ::view-transition-group(root)
│ └─ ::view-transition-image-pair(root)
│ ├─ ::view-transition-old(root)
│ └─ ::view-transition-new(root)
└─ ::view-transition-group(hero)
└─ ::view-transition-image-pair(hero)
├─ ::view-transition-old(hero)
└─ ::view-transition-new(hero)
| Pseudo-element | What it is | Default behavior |
|---|---|---|
::view-transition | The overlay root | Covers the snapshot containing block (the viewport including areas behind retractable browser UI and the on-screen keyboard). Forms a stacking context. |
::view-transition-group(name) | One per name | Animates width, height and transform from the old box to the new box, plus backdrop-filter. |
::view-transition-image-pair(name) | Container for old and new | Has isolation: isolate so the two images blend only with each other. |
::view-transition-old(name) | Static image of the old state | Replaced element (like an <img>), fades out. |
::view-transition-new(name) | Live representation of the new state | Replaced element, fades in. Updates if the element changes during the animation. |
If a name exists only in the old state, the group contains only ::view-transition-old() (an exit). If it exists only in the new state, it contains only ::view-transition-new() (an entry). Both are faded by default.
The old image is a flat bitmap: video stops, text is not selectable, and an element that was scrolled keeps its old scroll position. The new image is live, so a playing video keeps playing in ::view-transition-new().
The default animation, from the user-agent style sheet¶
The specification's user-agent style sheet explains exactly what you get for free. These are the rules that matter when you override them:
:root { view-transition-name: root; }
::view-transition { position: absolute; inset: 0; }
::view-transition-group(*) {
position: absolute;
top: 0;
left: 0;
animation-duration: 0.25s;
animation-fill-mode: both;
}
::view-transition-old(*),
::view-transition-new(*) {
position: absolute;
inset-block-start: 0;
inline-size: 100%;
block-size: auto;
}
/* Timing flows down the tree from the group. */
::view-transition-image-pair(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: inherit;
animation-fill-mode: inherit;
animation-delay: inherit;
animation-timing-function: inherit;
animation-iteration-count: inherit;
animation-direction: inherit;
animation-play-state: inherit;
}
@keyframes -ua-view-transition-fade-out { to { opacity: 0; } }
@keyframes -ua-view-transition-fade-in { from { opacity: 0; } }
For each name, the browser also injects a generated @keyframes -ua-view-transition-group-anim-<name> rule whose from keyframe holds the old transform, width, height and backdrop-filter. When a name has both an old and a new image, the old and new pseudo-elements also get a second UA animation, -ua-mix-blend-mode-plus-lighter, which sets mix-blend-mode: plus-lighter for the duration of the cross-fade. Inside the isolated image pair, that makes identical pixels in the old and new images add up to full opacity instead of dipping to roughly 75% halfway through. If you replace the animation shorthand on ::view-transition-old() or ::view-transition-new(), you remove that blend animation as well, which is why custom cross-fades sometimes show a slight "dip" that the default does not.
The ::view-transition root was originally specified as position: fixed. The CSS Working Group changed it to position: absolute (Chrome 142 follows the new definition). Its containing block is the snapshot containing block either way, so the only visible difference is the value getComputedStyle() reports.
Two consequences follow:
- Timing inherits. The image pair, old and new pseudo-elements have always inherited
animation-durationandanimation-fill-modefrom the group, so settinganimation-durationon::view-transition-group(*)slows the whole transition down. The spec later addedanimation-delayand thenanimation-timing-function,animation-iteration-count,animation-directionandanimation-play-stateto the inherited set. Chromium implements both changes from Chrome 140. In engines that predate them, an easing or delay set on the group applies to the group's size and position morph only, so set those properties on the old and new pseudo-elements explicitly when you need them everywhere. - Images stretch with the group. Old and new are laid out at
inline-size: 100%and natural aspect ratio inside a group whose size animates. When the aspect ratio changes (a thumbnail becoming a wide hero), both images are scaled and one of them overflows. You fix that withobject-fitandoverflow: clip, shown in Morphing an element between states.
What participating in a transition does to an element¶
A non-none view-transition-name is not free even when no transition is running. Per the spec, an element with a name (or captured in a transition) always:
- forms a stacking context,
- is flattened in 3D transforms,
- forms a backdrop root, so
backdrop-filterinside it stops seeing content behind it.
An element whose box is fragmented (split across columns or pages), not rendered (display: none), or inside a subtree that skips its contents (content-visibility: hidden, or auto while off-screen) is ignored for capture. That makes content-visibility: auto on long lists compatible with transitions: off-screen items simply don't participate.
During the animation the captured elements themselves are not painted (their pseudo-elements stand in for them), and the pseudo-element overlay is not interactive, so clicks and taps land on the document element rather than on your controls. Keep transitions short: users on a slow device who tap during a 600 ms animation will see their taps ignored.
Same-document transitions with document.startViewTransition()¶
Method signature and options¶
The Level 2 IDL is:
partial interface Document {
ViewTransition startViewTransition(
optional (ViewTransitionUpdateCallback or StartViewTransitionOptions) callbackOptions = {}
);
readonly attribute ViewTransition? activeViewTransition;
};
callback ViewTransitionUpdateCallback = Promise<any> ();
dictionary StartViewTransitionOptions {
ViewTransitionUpdateCallback? update = null;
sequence<DOMString>? types = null;
};
interface ViewTransition {
readonly attribute Promise<undefined> updateCallbackDone;
readonly attribute Promise<undefined> ready;
readonly attribute Promise<undefined> finished;
undefined skipTransition();
[SameObject] readonly attribute ViewTransitionTypeSet types;
readonly attribute Element transitionRoot;
undefined waitUntil(Promise<any> promise);
};
interface ViewTransitionTypeSet {
setlike<DOMString>;
};
You can call it three ways:
// 1. Callback only (Level 1: Chrome 111, Safari 18, Firefox 144)
document.startViewTransition(() => renderRoute(nextRoute));
// 2. Options object with types (Chrome 125, Safari 18.2, Firefox 147)
document.startViewTransition({
update: () => renderRoute(nextRoute),
types: ["forward", "slide"],
});
// 3. No update at all: animate a change you already made, or a pure style change
document.startViewTransition();
The callback may be synchronous or return a promise. The browser waits for the returned promise before capturing the new state. A callback-only engine (Safari 18.0 and 18.1, Firefox 144 to 146) cannot convert an options object to a callback and throws a TypeError synchronously, so check "types" in ViewTransition.prototype before passing an object. The wrapper later on this page does that.
The three promises and skipTransition()¶
| Member | Fulfills when | Rejects when | Typical use |
|---|---|---|---|
updateCallbackDone | The promise returned by your callback fulfills (immediately if there was no callback) | Your callback throws or its promise rejects | "Did the DOM change happen?" Use this when you don't care whether it animated. |
ready | The pseudo-element tree exists and animations are about to start | The transition is skipped before animating, for any reason | Start custom Web Animations on the pseudo-elements. |
finished | The end state is visible and interactive, pseudo-elements are removed | Only if updateCallbackDone rejects | Clean up temporary names, restore focus, re-enable UI. |
skipTransition() | n/a | n/a | Skips the animation. The update callback still runs. finished still settles. |
finished does not reject when the transition is skipped (by skipTransition(), a timeout or a duplicate name). It reflects the DOM update, not the animation. ready, on the other hand, rejects in all those cases. An unhandled ready rejection shows up as an unhandledrejection error in your monitoring. If the callback itself fails, the spec marks ready as handled and lets updateCallbackDone carry the error, so you get exactly one unhandled rejection instead of two.
When exactly finished resolves changed in Chrome 140. Previously the clean-up (removing the pseudo-element tree) ran inside the rendering steps, so the first frame your finished handler could influence was already painted without the transition. Code that swapped styles in finished to keep the end state looking identical (for example moving a view-transition-name from a temporary element to its permanent home) produced a one-frame flicker. Chrome 140 moved the clean-up to run after the rendering lifecycle, which means the frame produced right after finished resolves still contains the view transition structure, and style changes you make in the handler land in the same visual frame as the removal. The change is tracked in CSSWG issue 12442.
What can cause a transition to be skipped¶
The update always runs. The animation is skipped, and ready rejects with a DOMException, in these situations:
| Situation | Exception name | Notes |
|---|---|---|
The document is hidden when you call startViewTransition() | InvalidStateError | Background tabs, a minimized PWA window, or a phone with the screen off. The callback still runs. |
| The document becomes hidden during the transition | InvalidStateError | Page-visibility change steps skip the active transition. |
Another startViewTransition() call on the same document | AbortError | The new call skips the active one. Both callbacks may run concurrently, so your router must tolerate that. |
Two rendered elements share a view-transition-name at capture time | InvalidStateError | Checked separately for the old and the new state. Chromium logs the duplicate name to the console. |
| The snapshot containing block changed size between old and new capture | InvalidStateError | For example the device rotated or the window was resized mid-update. |
| Your callback throws or its promise rejects | the callback's reason | updateCallbackDone and finished reject too. |
| The update takes too long | TimeoutError | The spec leaves the duration implementation-defined. Chromium gives up after about four seconds. |
You call skipTransition() | AbortError | No-op on ready if the animation already started. |
The timeout is the one that bites in production. Rendering is frozen while you wait, so a callback that awaits a network request makes the app look hung and then snaps to the new state without animating. Fetch data first, then start the transition with a synchronous or near-synchronous DOM update.
document.activeViewTransition¶
document.activeViewTransition returns the running ViewTransition or null. It is available in Chrome 142, Firefox 147 and Safari 26.2. It saves you from threading the object through your code, and it lets independent components react to a transition they didn't start, for example a media player that pauses while the page animates. Where it's missing, keep your own reference, as the wrapper below does.
The :active-view-transition pseudo-class (Chrome 125, Safari 18, Firefox 144) matches the root element while a transition is active, which is handy for disabling hover effects or smooth scrolling during the animation:
/* Snapshots are taken with scroll positions frozen; smooth scrolling mid-transition
produces a visible jump when the real DOM reappears. */
html:active-view-transition {
scroll-behavior: auto;
}
A production wrapper for SPA updates¶
Every SPA ends up with the same helper: feature-detect, respect reduced motion, pass types when supported, never let an animation failure break navigation, and expose a promise for "the DOM is updated". This module does all of that and works in browsers without the API.
/**
* Run a DOM update, animated with a view transition where supported.
*
* @param {() => void | Promise<void>} update Must perform the DOM change. Keep it fast:
* rendering is frozen while it runs, and Chromium skips the animation after ~4 s.
* @param {object} [options]
* @param {string[]} [options.types] View transition types, e.g. ["forward"].
* @param {boolean} [options.animate=true] Pass false to force an instant update.
* @returns {Promise<{ animated: boolean, transition: ViewTransition | null }>}
* Resolves after the DOM update (not after the animation). Rejects if update() fails.
*/
export async function withViewTransition(update, { types = [], animate = true } = {}) {
const supported = typeof document.startViewTransition === "function";
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
// Fallback path: same semantics, no animation.
if (!supported || !animate || reduceMotion || document.visibilityState === "hidden") {
await update();
return { animated: false, transition: null };
}
// Level 2 engines accept an options object; Level 1 engines would throw a TypeError.
const supportsTypes =
typeof ViewTransition !== "undefined" && "types" in ViewTransition.prototype;
let transition;
if (supportsTypes && types.length > 0) {
transition = document.startViewTransition({ update, types });
} else {
// Emulate types with a class on <html> so the same CSS can key off either.
const root = document.documentElement;
const classes = types.map((t) => `vt-type-${t}`);
root.classList.add(...classes);
transition = document.startViewTransition(update);
// finished rejects if update() fails; clean up either way without creating a new
// unhandled rejection (a bare .finally() would re-throw the error).
const cleanup = () => root.classList.remove(...classes);
transition.finished.then(cleanup, cleanup);
}
// A skipped animation is not an error for the caller. Swallow it here so it never
// surfaces as an unhandledrejection, but keep it visible while developing.
transition.ready.catch((error) => {
if (error?.name !== "AbortError") {
console.debug(`[view-transition] skipped: ${error?.name}: ${error?.message}`);
}
});
// Propagate real failures of the DOM update to the caller.
await transition.updateCallbackDone;
return { animated: true, transition };
}
Two design choices are worth calling out. The function resolves on updateCallbackDone, not on finished, because routers and tests usually need "the new view exists" and shouldn't wait 250 ms or more for an animation. And the class fallback (vt-type-forward) lets you write direction-aware CSS that works in Safari 18.0 and Firefox 144 to 146 with the same selectors you use for real types (shown in Transition types).
Morphing an element between states¶
The classic "list thumbnail grows into the detail hero" effect needs the same name on the thumbnail in the old state and on the hero in the new state, and on no other element at either capture. Assign the name dynamically, only to the clicked item, and remove it afterwards:
import { withViewTransition } from "./view-transition.js";
/**
* Open a photo's detail view with a shared-element morph.
* @param {HTMLElement} thumb The clicked thumbnail <img>.
* @param {string} photoId
*/
export async function openPhoto(thumb, photoId) {
const data = await loadPhoto(photoId); // network first: rendering is not frozen yet
thumb.style.viewTransitionName = "photo"; // old state: only this thumbnail is named
const { transition } = await withViewTransition(
() => {
thumb.style.viewTransitionName = ""; // must not collide with the hero below
renderDetail(data); // renders <img class="hero" style="view-transition-name: photo">
},
{ types: ["forward"] },
);
// Remove the name once the animation ends so the hero doesn't keep a stacking
// context and backdrop root forever.
await transition?.finished;
document.querySelector(".hero")?.style.removeProperty("view-transition-name");
}
And the CSS that keeps the image from stretching when the aspect ratio changes:
/* The group animates the box; the images inside should be cropped, not squashed. */
::view-transition-old(photo),
::view-transition-new(photo) {
height: 100%;
object-fit: cover;
overflow: clip;
}
/* Skip the cross-fade: the thumbnail and hero show the same photo. */
::view-transition-old(photo) { animation: none; opacity: 0; }
::view-transition-new(photo) { animation: none; }
::view-transition-group(photo) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
Hiding the old image and not fading the new one works here because both show the same pixels. For elements whose content changes (a card that becomes a page with different text), keep the default cross-fade.
Naming many elements: match-element and auto¶
For lists that reorder (sorting, filtering, drag and drop), naming each item by hand is tedious. The view-transition-name: match-element keyword generates a unique name per element identity, so the same DOM node is paired with itself across the update:
.todo-list > li {
view-transition-name: match-element;
view-transition-class: todo; /* style all generated groups at once */
}
::view-transition-group(*.todo) {
animation-duration: 200ms;
}
match-element is supported in Chrome 137, Safari 18.4 and Firefox 144. It only matches the same element, so it only works for same-document transitions where your framework keeps DOM nodes stable (keyed lists).
The auto keyword behaves like match-element but uses the element's id as the name when it has one, which can pair elements across documents. Safari 18.2 ships auto. In Chromium it is still in development, and the W3C TAG has raised concerns about giving id a new meaning, so prefer match-element or explicit names in cross-browser code.
Styling transitions with view-transition-class¶
Pseudo-element selectors accept a name or *, and from Level 2 they also accept classes. view-transition-class (Chrome 125, Safari 18.2, Firefox 144) assigns one or more classes to the pseudo-elements generated for an element's name:
.card {
view-transition-class: card;
}
.card[data-pinned] {
view-transition-class: card pinned; /* multiple classes, like HTML class */
}
/* Matches ::view-transition-group(card-17), (card-42) and so on */
::view-transition-group(*.card) {
animation-duration: 350ms;
animation-timing-function: ease-in-out;
}
/* Both classes must match */
::view-transition-group(*.card.pinned) {
z-index: 2;
}
The name still identifies the element: classes never pair old with new, they only let one rule style many groups. A class in the new state takes precedence: the spec captures view-transition-class from the new element when both exist. The selector forms are ::view-transition-group(name.class), ::view-transition-group(*.class) and the same for -image-pair, -old and -new.
Transition types: choosing animations per navigation¶
Transition types are strings attached to a transition. They don't do anything by themselves. They make :active-view-transition-type() match on the root element for the duration of the transition, so you can select different animations for "forward", "back", "reload list" or "switch tab" without touching the DOM:
/* Default: cross-fade (inherited from the UA stylesheet). */
/* Forward navigation: new page slides in from the inline end. */
html:active-view-transition-type(forward) {
&::view-transition-old(root) { animation: 250ms ease-in both slide-out-to-start; }
&::view-transition-new(root) { animation: 250ms ease-out both slide-in-from-end; }
}
/* Back navigation: reverse. */
html:active-view-transition-type(back) {
&::view-transition-old(root) { animation: 250ms ease-in both slide-out-to-end; }
&::view-transition-new(root) { animation: 250ms ease-out both slide-in-from-start; }
}
/* Fallback for Level 1 engines, driven by the wrapper's classes. */
html.vt-type-forward::view-transition-old(root) { animation: 250ms ease-in both slide-out-to-start; }
html.vt-type-forward::view-transition-new(root) { animation: 250ms ease-out both slide-in-from-end; }
html.vt-type-back::view-transition-old(root) { animation: 250ms ease-in both slide-out-to-end; }
html.vt-type-back::view-transition-new(root) { animation: 250ms ease-out both slide-in-from-start; }
@keyframes slide-out-to-start { to { translate: -30% 0; opacity: 0; } }
@keyframes slide-in-from-end { from { translate: 30% 0; opacity: 0; } }
@keyframes slide-out-to-end { to { translate: 30% 0; opacity: 0; } }
@keyframes slide-in-from-start { from { translate: -30% 0; opacity: 0; } }
/* Right-to-left layouts: mirror the direction. */
html[dir="rtl"] { --vt-dir: -1; }
Rules to know:
:active-view-transition-type(a, b)matches if any listed type is active. Specificity is one pseudo-class.transition.typesis a liveSet-like object. You can add or delete types after starting the transition (for example inpagereveal), and selectors re-evaluate.- Types must be valid
<custom-ident>s to be usable in selectors.noneand strings beginning with-ua-are reserved. - Types are per document. In a cross-document transition the old and the new document each have their own set.
In production, wrap these movement rules in @media (prefers-reduced-motion: no-preference), as described in Reduced motion and accessible transitions. The --vt-dir custom property is a hook for mirroring: multiply your translate percentages by it (calc(30% * var(--vt-dir, 1))) so that "forward" moves toward the reading direction in right-to-left locales.
Types are especially useful for a global animation like the root slide above: without them, the root animation applies to every transition in the app, including small in-place updates where a slide would be absurd.
Custom animations with the Web Animations API¶
CSS covers most needs, but some effects depend on runtime data: a circular reveal from the point the user tapped, or a distance-dependent duration. Wait for ready, then animate the pseudo-elements with Element.animate() and the pseudoElement option:
import { withViewTransition } from "./view-transition.js";
/**
* Switch color theme with a circular reveal centered on the toggle button.
* @param {MouseEvent} event The click on the theme toggle.
*/
export async function toggleTheme(event) {
const root = document.documentElement;
const next = root.dataset.theme === "dark" ? "light" : "dark";
// Read everything from the event synchronously: event.currentTarget is reset to
// null as soon as dispatch ends, i.e. before the first await below resolves.
const rect = event.currentTarget.getBoundingClientRect();
// Keyboard activation (Enter/Space) produces a click with detail === 0 and
// clientX/clientY of 0, so start from the button's center in that case.
const fromPointer = event.detail > 0;
const x = fromPointer ? event.clientX : rect.left + rect.width / 2;
const y = fromPointer ? event.clientY : rect.top + rect.height / 2;
const radius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
const { transition } = await withViewTransition(
() => {
root.dataset.theme = next;
document
.querySelector('meta[name="theme-color"]')
?.setAttribute("content", next === "dark" ? "#0b1d3a" : "#0b57d0");
},
{ types: ["theme"] }, // scopes the CSS below to this transition only
);
if (!transition) return; // no API, reduced motion, or hidden: already switched
try {
await transition.ready; // rejects if the animation was skipped
} catch {
return;
}
root.animate(
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`] },
{ duration: 400, easing: "ease-in", pseudoElement: "::view-transition-new(root)" },
);
}
/* Disable the default cross-fade for this transition only: the new state is revealed,
not faded. Without the type (or the wrapper's fallback class) this rule would switch
off the root animation for every transition in the app. */
html:active-view-transition-type(theme)::view-transition-old(root),
html:active-view-transition-type(theme)::view-transition-new(root),
html.vt-type-theme::view-transition-old(root),
html.vt-type-theme::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
The transition ends when every animation on the pseudo-elements has finished, including those you start from script. An infinite animation keeps the transition (and the non-interactive overlay) alive forever, so always use finite durations.
Keeping a transition alive: waitUntil() (experimental)¶
Experimental
ViewTransition.waitUntil(promise) is in the Level 2 draft and shipped in Chrome 144. Safari and Firefox don't support it. Feature-detect with "waitUntil" in ViewTransition.prototype.
By default a transition finishes as soon as no animations are running on its pseudo-elements. waitUntil() delays that until your promise settles, which is useful when the pseudo-elements are driven by something other than a time-based animation, such as a scroll-driven animation or a gesture. Don't use it to wait for network data: the overlay blocks interaction the whole time.
Cross-document transitions for multi-page PWAs¶
Cross-document view transitions (Chrome and Edge 126+, Safari 18.2+) animate real navigations between same-origin documents. No JavaScript is required. Both the old and the new page must opt in:
The navigation descriptor accepts auto or none (the initial value). Because the rule is a normal at-rule, you can scope it with media queries:
/* Only animate page navigations for users who haven't asked for reduced motion. */
@media (prefers-reduced-motion: no-preference) {
@view-transition {
navigation: auto;
}
}
When a cross-document transition runs¶
The Level 2 algorithm allows the transition only when all of these hold:
- both documents are same-origin (subdomains don't count),
- the navigation has no cross-origin redirects in its chain,
- the navigation type is
traverse(back/forward, including the browser buttons and the Android back gesture), orpush/replacenot initiated from browser UI, - it is not a reload (neither user- nor script-initiated),
- the old page stays visible throughout, and
- both documents have
navigation: autoin effect at the relevant moment (the old one when the navigation is about to swap documents, the new one at its first rendering opportunity).
So typing a URL, choosing a bookmark, launching the installed app from its icon, reloading, and following a link from another origin never animate. The browser may also skip your transition when it shows its own navigation gesture animation (the spec allows this explicitly). Safari's edge-swipe back gesture on iOS is the common case.
Cross-document transitions also have a time limit. If the new page takes too long to be ready, the transition is skipped with a TimeoutError. Chrome's documentation puts that limit at four seconds, counted from the start of the navigation, so network time counts. For an installed PWA, serving navigations from a service worker cache (see Caching Strategies) or using Navigation Preload keeps you well inside that budget.
The pageswap and pagereveal events¶
Two events give script access to each side of the transition:
| Event | Fired on | When | event.viewTransition |
|---|---|---|---|
pageswap (PageSwapEvent) | Old document's window | Right before the old document's last frame is captured and it is swapped out | A ViewTransition if the navigation is eligible and the old page opted in, otherwise null |
pagereveal (PageRevealEvent) | New document's window | Before the first rendering opportunity of a new document, and also on bfcache restore and prerender activation | A ViewTransition if an inbound transition is active, otherwise null |
PageSwapEvent also has an activation property (a NavigationActivation) with entry (the destination NavigationHistoryEntry, whose url is the final URL after redirects), from and navigationType. In the new document, the same information is available as navigation.activation (Navigation API: Chrome 123, Firefox 147, Safari 26.2).
pageswap and pagereveal shipped in Chrome 124 and 123 respectively, with viewTransition on both events from Chrome 126, and in Safari 18.2. MDN's compatibility data notes that Safari doesn't fire pageswap for cross-origin navigations. Firefox doesn't support either event.
Register pagereveal in a classic script in the head
pagereveal fires before the first frame. A type="module", defer or async script at the end of the body can run too late and miss it. Put the listener in a small parser-blocking classic <script> in the <head>, or inline it.
Direction-aware MPA transitions¶
This pair of scripts sets forward/back types on both sides so the CSS from Transition types works for real page navigations. It also names the header so it stays put while the content slides.
<link rel="stylesheet" href="/styles/global.css">
<script src="/scripts/page-transitions.js"></script>
// Classic script, loaded synchronously in <head>, so pagereveal is never missed.
(() => {
/** Decide the direction from a NavigationActivation. */
function directionOf(activation) {
if (!activation) return null;
if (activation.navigationType === "traverse") {
const from = activation.from?.index ?? -1;
const to = activation.entry?.index ?? -1;
if (from !== -1 && to !== -1) return to < from ? "back" : "forward";
return "back"; // index unknown: history traversal is most often "back"
}
if (activation.navigationType === "push" || activation.navigationType === "replace") {
return "forward";
}
return null; // reload never transitions
}
// Old document: customize the outgoing half of the transition.
window.addEventListener("pageswap", (event) => {
if (!event.viewTransition) return;
const dir = directionOf(event.activation);
if (dir) {
event.viewTransition.types.add(dir);
// Hand the direction to the next document for engines without
// navigation.activation (Safari 18.2 to 26.1). Storage can throw; ignore it.
try { sessionStorage.setItem("vt-direction", dir); } catch {}
}
// Skip when leaving to a page that should not animate, e.g. a checkout flow.
const target = new URL(event.activation.entry.url);
if (target.pathname.startsWith("/checkout/")) event.viewTransition.skipTransition();
});
// New document: customize the incoming half. The animation runs in THIS document,
// so the types set here are the ones your ::view-transition-* rules see.
window.addEventListener("pagereveal", async (event) => {
let stored = null;
try {
stored = sessionStorage.getItem("vt-direction");
sessionStorage.removeItem("vt-direction"); // one-shot: never reuse a stale value
} catch {}
if (!event.viewTransition) return;
const dir = directionOf(window.navigation?.activation) ?? stored;
if (dir) event.viewTransition.types.add(dir);
// Catch skips (hidden page, timeout) so they don't reach error monitoring.
event.viewTransition.ready.catch(() => {});
});
})();
@view-transition {
navigation: auto;
}
/* Header and bottom tab bar are identical on every page: keep them still. */
.app-header { view-transition-name: app-header; }
.tab-bar { view-transition-name: tab-bar; }
::view-transition-group(app-header),
::view-transition-group(tab-bar) {
animation: none;
}
The pseudo-element tree of a cross-document transition lives in the new document, so the new document's styles and types decide how everything animates. Types added in pageswap only affect the old document, which is useful for rules that assign names conditionally at capture time (for example html:active-view-transition-type(back) .card { view-transition-name: … }). That is why the script adds the direction on both sides and why the sessionStorage hand-off matters: Safari 18.2 to 26.1 fire pagereveal but have no Navigation API, so without it the incoming page would never know the direction.
Types can also be declared in CSS: @view-transition { navigation: auto; types: slide; } sets the active types for transitions in that document. The descriptor applies only to the document that declares it, so use the script approach when the type depends on the navigation.
Shared elements across pages¶
Morphing a product card on a list page into the hero on the product page works exactly like the same-document version, except that the names live in two documents. The typical approach names the clicked card in pageswap and the hero in pagereveal, then removes both names when the transition finishes:
(() => {
const NAME = "product-image";
window.addEventListener("pageswap", async (event) => {
if (!event.viewTransition) return;
const url = new URL(event.activation.entry.url);
const match = url.pathname.match(/^\/products\/([\w-]+)$/);
if (!match) return;
const img = document.querySelector(`[data-product="${CSS.escape(match[1])}"] img`);
if (!img) return;
img.style.viewTransitionName = NAME;
// If this page is restored from bfcache, don't leave the name behind.
await event.viewTransition.finished;
img.style.viewTransitionName = "";
});
window.addEventListener("pagereveal", async (event) => {
if (!event.viewTransition) return;
const from = window.navigation?.activation?.from?.url;
if (!from || new URL(from).pathname !== "/products") return;
const hero = document.querySelector(".product-hero img");
if (!hero) return;
hero.style.viewTransitionName = NAME;
await event.viewTransition.finished.catch(() => {});
hero.style.viewTransitionName = "";
});
})();
For pagereveal to find .product-hero, that element must already be parsed at the first rendering opportunity. The next section covers how to guarantee that.
Render blocking and <link rel="expect">¶
The browser captures the new state at the first rendering opportunity. If your hero element is further down the HTML than the browser has parsed at that point, it simply won't be captured, and the morph degrades to a cross-fade. HTML's render-blocking mechanism lets you delay the first render until specific content is available:
<!-- Don't render the first frame until the element with id="hero" is parsed. -->
<link rel="expect" href="#hero" blocking="render">
<link rel="expect" blocking="render"> and the blocking="render" attribute on <script>, <link rel="stylesheet"> and <style> are supported in Chrome 124/105 and Safari 18.2. Use them sparingly: every render-blocking resource delays First Contentful Paint for every navigation, not just animated ones, and Chrome's guidance is to measure before you use them. Keep the expected element high in the document and within the first chunk of HTML. The broader trade-offs are covered on Loading Performance.
bfcache, prerendering and service workers¶
- Back/forward cache. When a page is restored from bfcache,
pagerevealfires again, and a traverse navigation can animate. Any names you set inpageswapare still on the elements when the page comes back, which is why the examples remove them infinished. - Prerendering. A page prerendered with speculation rules fires
pagerevealon activation, not while prerendering. Prerendering removes network time from the four-second budget, which makes it a natural companion to cross-document transitions. - Service workers. The transition doesn't care how the new document was produced: a network response, a cached response, or a streamed response from your service worker (see Streaming Responses) all work. What matters is how fast the first frame is ready. A navigation response served from the Cache Storage API is the most reliable way to stay under the timeout on flaky networks.
Choosing SPA or MPA transitions for a PWA¶
| Consideration | Same-document (SPA) | Cross-document (MPA) |
|---|---|---|
| Browser support (stable) | Chrome/Edge 111, Safari 18, Firefox 144 | Chrome/Edge 126, Safari 18.2 |
| JavaScript required | Yes: call startViewTransition() around your router's render | No for the basic effect; small classic scripts for types and dynamic names |
| Who controls timing | You: the update callback promise | The browser: first rendering opportunity, render-blocking resources |
| Timeout risk | Only if your callback is slow | Network and server time count |
| Transition on reload / URL bar / app launch | You decide | Never |
| Page state across screens (audio, forms) | Preserved | Lost unless persisted |
| Works offline | Yes | Yes, if navigations are served by the service worker |
Cross-document transitions have made the MPA architecture viable for app-like PWAs, which is a large part of the argument on SPA vs MPA PWAs. An app shell SPA still wins when screens share long-lived state.
Integrating with routers and frameworks¶
A vanilla router on the Navigation API¶
The Navigation API (Chrome 102, Firefox 147, Safari 26.2) is the natural place to hook view transitions into a hand-written SPA router: one navigate listener sees link clicks, form submissions, history calls and back/forward. This router loads data first, then renders inside a transition:
import { withViewTransition } from "./view-transition.js";
import { matchRoute } from "./routes.js";
let lastIndex = navigation.currentEntry?.index ?? 0;
navigation.addEventListener("navigate", (event) => {
// Only handle same-document-capable navigations to our own routes.
if (!event.canIntercept || event.hashChange || event.downloadRequest !== null) return;
const url = new URL(event.destination.url);
const route = matchRoute(url);
if (!route) return;
const toIndex = event.destination.index; // -1 for new entries
const direction =
event.navigationType === "traverse" && toIndex !== -1 && toIndex < lastIndex
? "back"
: "forward";
event.intercept({
// Accessibility: we move focus ourselves after rendering (see Accessibility page).
focusReset: "manual",
async handler() {
// 1. Slow work first, while the old screen is still interactive.
const data = await route.load(url, { signal: event.signal });
// 2. Fast, synchronous DOM update inside the transition.
await withViewTransition(() => route.render(data), {
types: [direction, route.transitionType ?? "page"],
// Don't animate traversals the browser may already animate itself (iOS swipe).
animate: !event.hasUAVisualTransition,
});
lastIndex = navigation.currentEntry.index;
route.focusTarget()?.focus();
},
});
});
NavigateEvent.hasUAVisualTransition is true when the browser has already shown its own animation for this navigation (for example a swipe-back gesture). Running your transition on top of that animates twice. Feature-detect the property: where it's missing it reads as undefined, and the code above treats that as "animate". The focus handling is explained on Accessibility.
In browsers without the Navigation API, the same router falls back to click interception plus popstate. Frameworks already do that for you.
Framework support¶
| Framework | How view transitions are exposed |
|---|---|
| React 19.3 | <ViewTransition> component (stable since React 19.3, released September 9, 2026). It animates updates inside startTransition, <Suspense> reveals and useDeferredValue updates (not urgent updates), choosing enter, exit, update or share animations, and addTransitionType() adds browser transition types usable with :active-view-transition-type(). |
| React Router | viewTransition prop on <Link>, <NavLink> and <Form>, plus the useViewTransitionState() hook to set names on the item being navigated to. |
| Angular | provideRouter(routes, withViewTransitions()), with an onViewTransitionCreated option that receives the transition for customization. The router skips it in unsupported browsers. |
| SvelteKit | No dedicated API: call document.startViewTransition() from onNavigate, returning a promise that resolves when the transition's update starts. |
| Astro | Cross-document transitions work with plain CSS. The <ClientRouter /> component adds an SPA-style router with its own fallback animations for browsers without the API. |
A minimal SvelteKit layout, following the pattern from the Svelte team's announcement post:
import { onNavigate } from "$app/navigation";
onNavigate((navigation) => {
if (!document.startViewTransition) return;
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
return new Promise((resolve) => {
document.startViewTransition(async () => {
resolve(); // let SvelteKit update the DOM...
await navigation.complete; // ...and capture the new state once it has
});
});
});
The general rule for any framework: the update callback must not resolve until the framework has committed the new DOM. If it resolves early, the new snapshot captures the old screen and the animation does nothing. If it never resolves, Chromium times out after about four seconds.
Reduced motion and accessible transitions¶
The View Transition API doesn't consult prefers-reduced-motion. The default 250 ms cross-fade is gentle, but slides, zooms and morphs can trigger vestibular symptoms, and WCAG 2.2 success criterion 2.3.3 (Animation from Interactions, level AAA) asks that motion triggered by interaction can be disabled. Handle it at three levels:
/* 1. Opt in to MPA transitions only when motion is welcome. */
@media (prefers-reduced-motion: no-preference) {
@view-transition { navigation: auto; }
/* 2. Keep every movement-based rule (slides, zooms, reveals) behind this query.
The direction-aware rules from "Transition types" belong here. */
html:active-view-transition-type(forward)::view-transition-new(root) {
animation: 250ms ease-out both slide-in-from-end;
}
/* ... */
}
/* 3. If you do run a transition for reduced-motion users, make it a short
cross-fade with no size or position morph. */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*) {
animation-duration: 0s; /* the group jumps straight to its final box */
}
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 150ms; /* set explicitly: they would otherwise inherit 0s */
}
}
Scoping the movement rules with no-preference is more robust than trying to override them under reduce: selectors such as html:active-view-transition-type(forward)::view-transition-new(root) are more specific than a blanket ::view-transition-new(*) override, and keyframe values always win over plain declarations anyway.
In JavaScript, skip the transition entirely when the query matches, as the wrapper earlier on this page does. Deciding up front is simpler than starting a transition and calling skipTransition(), and it avoids creating a ViewTransition whose rejected ready promise you then have to handle.
Other accessibility points:
- Focus. A transition doesn't move focus. In an SPA, move focus to the new view's heading after the update, and announce the route change. The patterns are on Accessibility.
- Input is blocked during the animation. Keep durations short (150 to 350 ms is typical for navigation), especially for keyboard and switch users who can't "wait out" a gesture.
- Screen readers don't perceive the pseudo-elements. The accessibility tree reflects the real DOM, which has already changed when the animation starts.
- Flashing. Avoid rapid brightness changes in theme-switch reveals: WCAG 2.3.1 limits flashes to three per second.
Performance considerations¶
- Rendering is suppressed during the update callback. Anything slow inside it shows up as a frozen screen and can hurt Interaction to Next Paint. Pre-fetch data, pre-decode images (
await img.decode()) and only do the DOM swap inside the callback. - Every named element is a texture. Hundreds of named elements (for example,
match-elementon a 1,000-item list) cost memory and capture time. Limit names to what is on screen, whichcontent-visibility: autodoes for you, since skipped elements aren't captured. - Animate compositor-friendly properties. Your keyframes on
::view-transition-old/newshould useopacity,transform,translate,scaleandclip-path. The group's size animation (width/height) is the browser's, and is cheap because it scales an image. - Large ink overflow (big shadows, blurs) enlarges snapshots. Implementations may clip or down-sample very large captures.
- Low-end devices. Measure on a real mid-range Android phone. If transitions drop frames, shorten them or reduce names. More profiling guidance is in Runtime Performance.
Element-scoped transitions (experimental)¶
Experimental
element.startViewTransition(), element.activeViewTransition, ViewTransition.transitionRoot and the view-transition-scope property shipped in Chrome 147. Safari and Firefox don't support them. They are defined in the Level 2 editor's draft and may change.
Document-level transitions are exclusive: starting one aborts any other, and the overlay covers the whole page. Scoped transitions run on a subtree. The pseudo-element tree is attached to the scope element, only names inside it are captured, the rest of the page stays interactive, and several scoped transitions can run at once (for example two independent carousels):
const list = document.querySelector(".carousel");
function showSlide(index) {
const update = () => renderSlide(list, index);
if (typeof list.startViewTransition === "function") {
list.startViewTransition(update); // only .carousel is snapshotted and animated
} else if (document.startViewTransition) {
document.startViewTransition(update); // whole-document fallback
} else {
update();
}
}
Chrome 140 also added nested view transition groups through the view-transition-group property (normal | contain | nearest | <custom-ident>). They let a group's children be nested inside it in the pseudo-tree, generating a ::view-transition-group-children() pseudo-element, so a parent's clip-path or 3D transform applies to its children during the animation. This is also Chromium-only today, and MDN still lists it as experimental.
Browser support¶
Support data as of September 2026. For live data, see MDN: View Transition API and caniuse: View Transitions.
| Feature | Chrome / Edge | Safari (macOS, iOS, iPadOS) | Firefox |
|---|---|---|---|
document.startViewTransition(callback) | ✅ 111 | ✅ 18 | ✅ 144 |
ViewTransition: ready, finished, updateCallbackDone, skipTransition() | ✅ 111 | ✅ 18 | ✅ 144 |
view-transition-name, pseudo-elements | ✅ 111 | ✅ 18 | ✅ 144 |
:active-view-transition | ✅ 125 | ✅ 18 | ✅ 144 |
view-transition-class | ✅ 125 | ✅ 18.2 | ✅ 144 |
Options object, types, :active-view-transition-type() | ✅ 125 | ✅ 18.2 | ✅ 147 |
view-transition-name: match-element | ✅ 137 | ✅ 18.4 | ✅ 144 |
view-transition-name: auto | ❌ | ✅ 18.2 | ❌ |
Cross-document @view-transition | ✅ 126 | ✅ 18.2 | ❌ |
pagereveal / pageswap | ✅ 123 / 124 | ✅ 18.2 ⚠️ | ❌ |
<link rel="expect">, blocking="render" | ✅ 124 / 105 | ✅ 18.2 | ❌ |
document.activeViewTransition | ✅ 142 | ✅ 26.2 | ✅ 147 |
Nested groups (view-transition-group, ::view-transition-group-children()) | 🧪 140 | ❌ | ❌ |
ViewTransition.waitUntil() | 🧪 144 | ❌ | ❌ |
Element-scoped transitions, view-transition-scope | 🧪 147 | ❌ | ❌ |
Notes:
- Chrome and Edge rows apply to desktop and Android. Samsung Internet follows Chromium (same-document from 22, cross-document from 28).
- 🧪 marks features MDN flags as experimental even though they're enabled by default in Chrome.
- ⚠️ Safari doesn't fire
pageswapfor cross-origin navigations.event.viewTransitionon both events arrived in Chrome 126. - Firefox's same-document support in 144 didn't include types. Cross-document transitions are tracked in Mozilla's View Transitions Level 2 meta bug. Same-document and cross-document view transitions are both focus areas of Interop 2026.
- iOS and iPadOS home screen web apps use the system WebKit, so their support follows the OS's Safari version.
Common pitfalls¶
- Awaiting the network inside the update callback. Rendering is frozen, the app looks hung, and after about four seconds Chromium skips the animation with a
TimeoutError. Load first, then transition. - Duplicate names. A static
view-transition-name: cardon every card in a list skips every transition withInvalidStateError. Use unique names,match-element, or set the name only on the one element that morphs. - Unhandled
readyrejections. Skipped transitions rejectready. Add.catch()wherever you touch it, or you will see a steady stream ofAbortErrorandInvalidStateErrorreports in error monitoring. - Resolving the callback before the framework commits. The new snapshot equals the old one and nothing animates. Wait for the framework's "DOM updated" signal (
flushSync,navigation.complete,await nextTick()). - Passing an options object to a Level 1 engine. Safari 18.0 and 18.1 and Firefox 144 to 146 throw
TypeError. Detect"types" in ViewTransition.prototype. - Opting in only one page. Cross-document transitions need
@view-transition { navigation: auto; }on both documents. Put it in the shared stylesheet. - Expecting a transition on app launch or reload. Neither is eligible. Use your splash and loading states instead (see Splash Screens & Theming).
- Leaving names on elements. Names create stacking contexts and backdrop roots, which can break
z-indexlayering andbackdrop-filteron overlays. Remove temporary names infinished. - Aspect-ratio changes that look squashed. Add
object-fit: cover; height: 100%; overflow: clip;to the old and new pseudo-elements for morphing images. - Fixed headers jumping. A
position: fixedelement that is not named is part of the root snapshot and slides with the page. Name it so it gets its own group, and disable its animation if it doesn't change.
Debugging¶
- Slow it down. In Chrome DevTools, open the Animations panel and set playback to 10% or pause. View transition animations appear there, and while paused you can inspect
::view-transitionand its children under<html>in the Elements panel and edit their styles live. -
Log the lifecycle. Temporarily attach handlers to all three promises to see which one rejects and with which
DOMExceptionname:DevTools consoleconst t = document.startViewTransition(() => {/* ... */}); t.updateCallbackDone.then(() => console.log("updated"), (e) => console.error("update", e)); t.ready.then(() => console.log("ready"), (e) => console.warn("skipped", e.name, e.message)); t.finished.then(() => console.log("finished")); -
Find duplicate names. Run
[...document.querySelectorAll("*")].map((el) => getComputedStyle(el).viewTransitionName).filter((n) => n !== "none")before and after the update and look for repeats. - Cross-document not firing? Check that both pages have the at-rule in effect (
CSSViewTransitionRuleobjects appear indocument.styleSheets), that the navigation is same-origin without redirects through another origin, that you aren't reloading, and thatpagerevealis registered in a classic head script. - Safari. Use Web Inspector's Graphics tab to see animations, and remember that iOS may substitute its own swipe animation for back navigations.
More general tooling is covered on Browser DevTools.
Further reading¶
On this site
- SPA vs MPA PWAs
- App-Like UX Patterns
- Accessibility
- Responsive & Adaptive Design
- App Shell Model
- Navigation Preload
- Loading Performance
- Framework Integrations
External references
- CSS View Transitions Module Level 1 and the Level 2 editor's draft
- MDN: View Transition API
- MDN:
Document.startViewTransition() - Chrome for Developers: Same-document view transitions
- Chrome for Developers: Cross-document view transitions
- Chrome for Developers: What's new in view transitions (2025 update)
- WebKit: WebKit Features in Safari 18.2
- React: React 19.3 release post