Skip to content

View Transitions

A UI principle for coding agents. Also covers view transitions, startViewTransition, view-transition-name, view-transition-class, view-transition-group, shared element transition, and 6 more.

Show all 12 aliases

view transitions, startViewTransition, view-transition-name, view-transition-class, view-transition-group, shared element transition, page transitions, route transitions, cross-document transitions, match-element, morph between elements, exit animations

Core Philosophy

  • A view transition is the browser doing the work the FLIP technique used to: it screenshots the page before your change, screenshots it after, and tweens between the two. You describe the end state and it finds the movement
  • The unit of animation is the name. An element with a view-transition-name becomes its own animated group; everything unnamed is one flat snapshot of the rest of the page
  • Almost every bug in this API comes from the same root cause: during a transition the page you see is a stack of snapshots, not the live DOM. Snapshots do not clip, do not receive clicks, and do not sit where your CSS thinks they do
  • The basic call is small enough to learn in a minute. The traps below are the part that takes a day, so read them before shipping a transition on a real surface
  • Scroll-driven animation lives in scroll-and-view-transitions.md. This doc is the navigation and state-change layer

The basic shape

  • Wrap the DOM update in document.startViewTransition. The callback is where the change happens, and it may return a promise if the update is async
function selectItem(id) {
  if (!document.startViewTransition) return update(id);
  document.startViewTransition(() => update(id));
}
  • The default is a cross-fade of the entire page. Everything past that comes from naming elements
  • The call returns a ViewTransition object with three promises and one method, all of which matter for real integration:
    • updateCallbackDone fulfils when your callback's promise settles, which is when the DOM is actually new
    • ready fulfils when the pseudo-element tree exists and the animation is about to run. This is where you attach a Web Animations API animation if you want one
    • finished fulfils when the animation has finished and the page is showing live DOM again
    • skipTransition() drops the animation but still applies the DOM update

The pseudo-element tree

Every named group generates four pseudo-elements, and knowing the shape tells you which one to style.

::view-transition                     overlay covering the viewport
  ::view-transition-group(name)       positioned and sized, tweens between old and new geometry
    ::view-transition-image-pair(name)  isolation container for blending
      ::view-transition-old(name)     snapshot of the outgoing state
      ::view-transition-new(name)     live representation of the incoming state
  • Style ::view-transition-old(name) for exit motion and ::view-transition-new(name) for entry motion. That is the layer you want in the overwhelming majority of cases
  • ::view-transition-group(name) owns position and size. The browser is already animating those, which is why writing your own transform on it is the trap described below
  • Use * to hit every group at once: ::view-transition-group(*) { animation-duration: 400ms; }
  • Debug by slowing everything down. Setting a 20 second duration on ::view-transition-group(*) makes it obvious which pseudo-element is misbehaving

The root group captures the whole page

This is the first thing to understand and the least obvious.

  • startViewTransition is a method on document, so by default every pixel on the page is captured into ::view-transition-group(root). Naming an element lifts it out of that group; everything else stays inside the flat root snapshot
  • Two consequences follow, and both read as bugs. Unnamed elements cannot animate independently, so a sibling that should stay put appears to fade or slide with the whole page. And nothing on the page is interactive while the transition runs, because the snapshot layer is on top
  • Opting the root out takes two rules, and both are needed. The first stops the page being captured as one flat snapshot. The second makes the overlay click-through, which is what actually restores interactivity, because the pseudo-element layer sits above the live DOM whether or not the root is named:
:root {
  view-transition-name: none;
}

::view-transition {
  pointer-events: none;
}
  • Once the root is disabled, an element with no name and no named ancestor is not captured into any group at all. It is not hidden by that: it keeps painting as live DOM for the whole transition, so a button that just sits there stays on screen without a name. none only opts an element out of its own snapshot, and an unnamed element still rides along inside the nearest named ancestor's group when it has one
  • What you give up by leaving something unnamed is independent motion, not visibility. Name an element when it needs its own animation, or when it needs to paint above the snapshots: the transition layer sits on top of ordinary content and no z-index on a real element lifts it over the pseudo-elements, so being named is the only way to get in front of another group

Element-scoped transitions

The traps above all come from one design decision, that the transition is owned by the document. Calling the API on an element instead removes most of them.

const panel = document.querySelector(".panel");
panel.startViewTransition(() => updatePanel());
  • The scope becomes that element's subtree, and the pseudo-element tree is built inside the element rather than under the root. Only DOM changes inside that subtree take part
  • What this buys you: the rest of the page is untouched, so nothing outside the scope is captured or covered by an overlay, and two independent transitions on different parts of the page stop interfering with each other
  • The returned object exposes transitionRoot, which is the element the transition is scoped to. For a document-scoped transition the same property points at the document element
  • It cannot be used for cross-document navigation, which remains document-scoped by definition
  • It is much newer than the rest of the API, so treat it as progressive enhancement: feature-detect Element.prototype.startViewTransition and fall back to the document-scoped call with the root opted out

Naming rules

  • Names must be unique in the DOM at any given moment. Two elements sharing a name cancels the transition silently, with no console error
  • view-transition-name: match-element makes the browser generate a unique name per element, which removes the need to derive stable unique names from your data. It cannot be used for cross-element morphs, where the shared name is the matching mechanism
  • view-transition-class applies one block of rules to many named groups. Use it whenever a list or grid needs a unique name per cell but identical animation, so the naming stays per element and the styling stays in one place
  • Pseudo-elements can carry names. A nav underline built as .link.active::after becomes a sliding indicator with no extra DOM: name the pseudo-element and swap the active class inside the transition

Morphing between two different elements

  • Two different elements can share a name as long as only one exists at a time. The browser then tweens one into the other
  • This is the whole implementation of a thumbnail expanding into a detail view, a card growing into a dialog, or a nav indicator sliding between links. It replaces measuring with getBoundingClientRect and applying transforms, along with the reflow bugs that approach carried
  • The two elements are genuinely different nodes, created and destroyed rather than moved. The API only requires that one holds the name at a time
  • When the two have different aspect ratios the snapshots visibly stretch. Pin the dimension that should not morph directly on the snapshots:
::view-transition-old(indicator),
::view-transition-new(indicator) {
  height: 3px;
}
  • If the element changes size, shape and content all at once, a smeared cross-fade is the API telling you it is the wrong tool. A plain CSS transition on the property that actually changed usually looks better

Do not put a transform on the group

  • The browser positions each group with a generated keyframe whose first frame carries a matrix(...) translation. Writing your own transform on ::view-transition-group(name) overwrites that matrix, and the snapshot animates from or to the top-left corner of the viewport
  • "My exit animation flies to the corner" is the single most common report, and this is always why
  • The fix, in order of preference:
    1. Move the animation down a level. Target ::view-transition-old(name) and ::view-transition-new(name), which have no positioning matrix to clobber
    2. If it must live on the group, add animation-composition: add so your transform sums with the browser's instead of replacing it. It is supported more widely than View Transitions itself, so it costs nothing to add
  • Related, and it inverts the usual advice: prefer animating width and height over transform: scale() inside a transition. The group already animates its own dimensions, there is no sub-pixel benefit here, and a transform risks the matrix problem above

Clipped children escape their parent

  • Snapshots are painted into a flat layer that has no clipping ancestor, so a named child of an overflow: hidden parent renders un-clipped for the duration of the transition. An avatar inside a rounded card spills outside the card and then snaps back
  • The fix has two halves. Name the clipping parent and give it view-transition-group: contain, which nests descendant groups inside the parent's group rather than making them siblings. Then repeat the clipping declarations on the pseudo-element, because the pseudo-element does not inherit them from the real element
@supports (view-transition-group: contain) {
  .card {
    view-transition-name: card;
    view-transition-group: contain;
  }

  ::view-transition-group(card) {
    overflow: hidden;
    border-radius: 16px;
  }
}
  • Never merge those two rules into one selector list. A browser that does not recognise the pseudo-element throws away the entire rule, so .card, ::view-transition-group(card) { overflow: hidden } leaves the real card with no clipping at all, permanently, not only during transitions. An unknown selector in a list invalidates the whole list, which is worth remembering well beyond this API
  • view-transition-group: contain is newer than the rest of the API, so gate it with @supports and treat the nesting as an enhancement

Named elements cannot be clicked during a transition

  • An element taking part in a transition has its pixels moved into the pseudo-element rather than copied, and in practice it stops responding to clicks at its real location for the duration
  • ::view-transition { pointer-events: none } is required, and it restores clicks on every unnamed element still painting as live DOM. What it cannot rescue is the named element itself, which is why the bug looks intermittent: the unnamed half of the page comes back and the named half does not
  • This is not an implementation difference to test your way around. The specification has participating elements skip hit testing, and there is no property that opts back in. Keyboard activation keeps working because the accessibility tree is untouched
  • Two workarounds:
    1. Lay the UI out so the trigger does not overlap the transitioning element, and leave the trigger unnamed
    2. Split identity: a real <button> with no name, which stays clickable underneath, plus a visually identical aria-hidden="true" element that carries the name and gets painted above
  • Keeping durations short is the other half of the answer, because the dead window is exactly the animation duration

Interruptions

  • A running transition cannot be smoothly retargeted. Starting a new one fast-forwards the running one to its end state, which reads as a glitch
  • Beyond keeping durations short, design the easing to survive being cut off. An aggressively front-loaded ease-out covers most of the distance in the first fifth of its duration, so an interrupted transition is already close to its destination when it jumps
::view-transition-group(*) {
  animation-duration: 700ms;
  animation-timing-function: cubic-bezier(0.08, 0.25, 0, 1);
}
  • Capping durations at around 250ms also works, and needs no thought, at the cost of motion that feels rushed
  • For rapid repeat interactions, hold the previous handle and call skipTransition() on it before starting the next, rather than letting them queue

Exit animations and giving one element two different exits

  • View transitions are how you get a real exit animation without keeping dismissed content mounted. Content that stays in the DOM to animate out remains tabbable unless you also manage inert, and it holds memory. Here the element can actually be removed
  • To give the same element different exit motion per action, set the name immediately before the update and style each name separately
dialog.style.viewTransitionName = confirmed ? "dialog-confirm" : "dialog-cancel";
document.startViewTransition(() => dialog.remove());

Stacking order

  • Groups stack the way their source elements stacked in the real DOM
  • In a dismissible list, the item being removed therefore fades out in front of the siblings sliding up to fill its space, which looks wrong
  • Fix it on the real elements, by giving them explicit z-index values in the order you want them to overlap during the transition

Cross-document transitions

  • Same-document transitions animate a change on the current page. Cross-document transitions animate a real navigation between two documents, which is what fixes the jump when a header shifts position between two page templates
  • Both documents must opt in, and they must be same origin. Same origin means the same scheme, host and port, all three, so a move between a site and its subdomain does not qualify
@view-transition {
  navigation: auto;
}
  • The browser matches names against the newly rendered document, so a participating element that is fetched after first paint will not match and simply cross-fades in. Server-render the element that takes part, or block rendering until it exists
  • Client-side route changes are same-document, because the framework updates the current document. A full page load, an external link or a hard refresh is a true cross-document navigation

Transition types

  • Types let one set of names carry different animation per kind of navigation, which is how forward and back get opposite directions without duplicating names
document.startViewTransition({
  update: () => render(nextChapter),
  types: ["forwards"],
});
html:active-view-transition-type(forwards) {
  &::view-transition-old(chapter) { animation-name: slide-out-to-left; }
  &::view-transition-new(chapter) { animation-name: slide-in-from-right; }
}

html:active-view-transition-type(backwards) {
  &::view-transition-old(chapter) { animation-name: slide-out-to-right; }
  &::view-transition-new(chapter) { animation-name: slide-in-from-left; }
}
  • The cross-document form declares types on the at-rule instead: @view-transition { navigation: auto; types: slide; }

In React

  • Do not call the API during render. It has to wrap the state update that causes the DOM change
  • Without the dedicated component, wrap the update and flush it synchronously, otherwise React batches the update outside the callback and the browser screenshots an unchanged DOM
document.startViewTransition(() => {
  flushSync(() => setSelectedId(nextId));
});
  • React's ViewTransition component is still experimental and ships in canary builds, so check what your installed version actually exports. Three behaviours to know before adopting it:
    • It only starts a transition when the update is inside a React transition, so the state update must be wrapped in startTransition or the component silently does nothing
    • Wrap each item individually, not the list. Wrapping the list derives names from array index, so a reorder cross-fades text in place instead of moving items, which is the same failure as using an index for key
    • It queues and coalesces interrupting updates rather than letting them interrupt. If true interruption matters, call document.startViewTransition yourself
  • Router support varies. Next.js App Router exposes an experimental viewTransition flag that rides on the canary component; React Router takes a viewTransition prop on Link and the same option on useNavigate; TanStack Router takes viewTransition in its navigate options. Astro's default multi-page navigation is a plain full page load with no transition at all: its client-router opt-in adds same-document transitions, while native cross-document ones need the @view-transition at-rule on both same-origin pages like any other site

Reduced motion

Two valid strategies, and the better one is usually not the blunt one.

  • Skipping startViewTransition entirely and applying the update directly gives an abrupt swap
  • Better in most cases: keep the transition and gate only your custom keyframes, so reduced-motion users land on the browser's default cross-fade, which involves no movement
@media (prefers-reduced-motion: no-preference) {
  ::view-transition-old(card) { animation: slide-out 300ms ease-in; }
  ::view-transition-new(card) { animation: slide-in 300ms ease-out; }
}
  • Check case by case, because the default is only motion-free when the element is not also changing position or size
  • Reduced motion means cutting the movement, never the change. The new state must still arrive

Common mistakes

  • Leaving the root named, so every unnamed sibling is swept into the page snapshot and fades or slides with it instead of holding still, and nothing on the page is clickable
  • Duplicate view-transition-name values in the DOM, which cancels the transition with no error
  • Writing a transform on ::view-transition-group, which overwrites the browser's positioning matrix and throws the element at the viewport corner
  • Putting a real element and a transition pseudo-element in the same selector list, which silently drops the rule in browsers that do not know the pseudo-element
  • Expecting pointer-events: none on the overlay to restore clicks on a named element, when the element is not hit-testable at all
  • Assuming a cross-document transition failed because of your CSS, when the participating element is rendered after first paint and never matched
  • Reaching for a view transition to animate a shape and content change at once, where a plain CSS transition on the one property that changed looks better
  • Cutting the transition entirely under reduced motion when gating only the custom keyframes would have kept a calm cross-fade

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-ui-principle({ topic: "view-transitions" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems