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-namebecomes 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
ViewTransitionobject with three promises and one method, all of which matter for real integration:updateCallbackDonefulfils when your callback's promise settles, which is when the DOM is actually newreadyfulfils 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 onefinishedfulfils when the animation has finished and the page is showing live DOM againskipTransition()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.
startViewTransitionis a method ondocument, 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.
noneonly 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-indexon 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.startViewTransitionand 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-elementmakes 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 mechanismview-transition-classapplies 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::afterbecomes 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
getBoundingClientRectand 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 owntransformon::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:
- Move the animation down a level. Target
::view-transition-old(name)and::view-transition-new(name), which have no positioning matrix to clobber - If it must live on the group, add
animation-composition: addso 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
- Move the animation down a level. Target
- Related, and it inverts the usual advice: prefer animating
widthandheightovertransform: 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: hiddenparent 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: containis newer than the rest of the API, so gate it with@supportsand 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:
- Lay the UI out so the trigger does not overlap the transitioning element, and leave the trigger unnamed
- Split identity: a real
<button>with no name, which stays clickable underneath, plus a visually identicalaria-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-indexvalues 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
ViewTransitioncomponent 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
startTransitionor 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.startViewTransitionyourself
- It only starts a transition when the update is inside a React transition, so the state update must be wrapped in
- Router support varies. Next.js App Router exposes an experimental
viewTransitionflag that rides on the canary component; React Router takes aviewTransitionprop onLinkand the same option onuseNavigate; TanStack Router takesviewTransitionin 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-transitionat-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
startViewTransitionentirely 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-namevalues in the DOM, which cancels the transition with no error - Writing a
transformon::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: noneon 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