Skip to content

API in Performance

View Transitions API

Published Updated

document.startViewTransition(updateCallback) captures the current page as a set of snapshots, runs the callback that changes the DOM, captures the new state, and animates between the two with CSS, defaulting to a cross-fade. The same mechanism applies to cross-document navigations when both pages opt in with the @view-transition rule, which gives a multi-page PWA the animated route changes that previously required a client-side router.

document.startViewTransition(updateCallback)
document.startViewTransition({ update, types })
transition.updateCallbackDone
transition.ready
transition.finished
transition.skipTransition()
@view-transition { navigation: auto; }

The method returns a ViewTransition synchronously. Same-document transitions are in Chrome 111, Safari 18, and Firefox 144 (BCD api.Document.startViewTransition); the cross-document @view-transition rule is in Chrome 126 and Safari 18.2 and absent from Firefox (BCD css.at-rules.view-transition).

Parameter Type Meaning
updateCallback function returning a value or promise Changes the DOM. The old snapshot is captured before it runs and the new one after its returned promise settles; rendering is paused in between, so keep it short.
options.update function Same as updateCallback, in the object form.
options.types array of strings Level 2: transition types matched by :active-view-transition-type() so one stylesheet can animate “forward” and “back” differently.
transition.updateCallbackDone Promise<void> Settles when the callback’s promise settles.
transition.ready Promise<void> Settles when the pseudo-elements exist and the animation is about to start; the point to attach a Web Animations API animation. Rejects when the transition is skipped.
transition.finished Promise<void> Settles after the animation ends and the pseudo-elements are removed.

The animation runs on a pseudo-element tree rooted at ::view-transition: for each view-transition-name (the default root plus every element given one in CSS) there is a ::view-transition-group(name) holding a ::view-transition-image-pair(name) with ::view-transition-old(name) (a bitmap of the old state) and ::view-transition-new(name) (a live view of the new one). The group animates position and size between the two states; the old and new images cross-fade; all of it is restyled with ordinary CSS animations.

Exception When
InvalidStateError DOMException Two elements share a view-transition-name at capture time. The transition is skipped, ready rejects, and the DOM update still applies.
TimeoutError DOMException The update callback’s promise takes longer than the engine’s limit (4 s in Chromium); the transition is skipped and the update applies.
AbortError DOMException A second startViewTransition() call starts before the first finishes, skipping the first; skipTransition() rejects ready the same way.
Any error thrown by updateCallback Rejects updateCallbackDone, ready, and finished; the DOM is left as the callback left it.

A transition that is skipped is not a failure of the update: the callback’s changes are applied in every case above, which is what makes the API safe to call unconditionally once feature-detected.

The single-page example animates a shared element; the multi-page one needs only CSS; the last is the guard.

Animating a list-to-detail navigation in a single-page app

Section titled “Animating a list-to-detail navigation in a single-page app”

The thumbnail and the detail hero share a view-transition-name set just before the transition, so the group animates the image from one place to the other instead of cross-fading the whole page.

async function openDetail(item) {
const thumb = document.querySelector(`[data-id="${item.id}"] img`);
thumb.style.viewTransitionName = 'hero';
const transition = document.startViewTransition(async () => {
await renderDetail(item); // replaces the list with the detail view
document.querySelector('.detail img').style.viewTransitionName = 'hero';
});
await transition.finished;
thumb.style.viewTransitionName = ''; // one element per name at the next capture
}

Clearing the name afterwards matters: a later transition that finds two hero elements is skipped with InvalidStateError.

::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 250ms;
}
::view-transition-group(hero) {
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}

The root cross-fade and the hero group are styled independently; without the first rule the default cross-fade lasts 250 ms as well, and the second only changes the hero’s easing.

Opting a multi-page app into cross-document transitions

Section titled “Opting a multi-page app into cross-document transitions”

Both the outgoing and the incoming page carry the rule; the browser captures the old page on pageswap and the new one on pagereveal, and the same pseudo-elements animate. Same-origin navigations only.

@view-transition {
navigation: auto;
}
header {
view-transition-name: site-header;
}

Giving the header a name keeps it still while the rest of the page cross-fades; a prerendered next page (see speculation rules) makes the transition start without a network wait.

Detecting support and skipping the animation

Section titled “Detecting support and skipping the animation”

The method is absent in engines without the API. The fallback applies the DOM change directly, which is the same end state without the animation.

function transitionTo(update) {
if (!('startViewTransition' in document)) {
return update(); // no View Transitions: update without animation
}
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
return update(); // user asked for less motion: skip the animation
}
return document.startViewTransition(update).finished;
}

Returning finished lets callers await the end of the animation before, for example, focusing the new view’s heading.

Specifications

SpecificationStatus
None.