View Transitions for CSS Developers: the Pseudo-Element Tree, Snapshots, and Named Elements
Most CSS animation work is about persuading a single element to move between two states you control. The View Transitions API inverts that: it lets the browser animate between two renderings of the whole page, including elements that were destroyed and elements that did not exist a moment ago. That is something no amount of transition or @keyframes could ever express, because both of those need a live element that persists across the change. This guide sits inside CSS-Only Micro-Interactions & Animations and approaches the feature the way a CSS author meets it in practice: as a generated tree of pseudo-elements you style, with a very small JavaScript or at-rule trigger attached to the front.
The mental shift is worth stating up front. You are not animating your DOM. You are animating pictures of it. Once that clicks, every strange behaviour of the API — why named elements must be unique, why text reflow looks like a squash, why position: fixed elements jump — stops being strange and becomes a direct consequence of the model.
What this guide covers:
- The capture-mutate-capture-animate lifecycle and what a "snapshot" really is
- The four pseudo-elements the browser generates, and which one you should target for what
view-transition-nameas the mechanism that splits the page into independently animated groups- The
@view-transition { navigation: auto }rule that extends all of this across page loads
Prerequisites
Before working through this guide you should be comfortable with:
- Writing
@keyframesand applying them with theanimationshorthand, includinganimation-duration,animation-timing-function, andanimation-fill-mode. - CSS pseudo-elements and how a selector like
::beforetargets something with no markup of its own. - Stacking contexts and
z-index, since the transition overlay creates its own. prefers-reduced-motionand why motion needs a user-preference escape hatch.- The difference between state-driven and time-driven animation, covered in CSS Transition Fundamentals.
The core mechanism: capture, mutate, capture, animate
A view transition is a four-phase operation the browser runs on your behalf.
Phase one — capture the old state. The browser paints the current document and stores the result. Elements carrying a view-transition-name are captured individually; everything else is flattened together into one image called the root snapshot. Alongside each image the browser records geometry: the element's border-box size, its position in the viewport, and its transform.
Phase two — apply your DOM change. Rendering is suspended while this happens. You swap classes, replace innerHTML, navigate, sort a list — whatever the change is. Because rendering is paused, the user never sees the intermediate frame where old content has gone and new content has not painted. This alone eliminates an entire family of flicker bugs.
Phase three — capture the new state. The browser repeats phase one against the mutated document, producing a second set of images and a second set of geometry records.
Phase four — build the overlay and animate. The browser constructs a tree of pseudo-elements above everything else on the page, populates it with the two sets of captures, applies default animations, and runs them. When the animations finish, the tree is torn down and the real, live, interactive DOM is revealed underneath.
The critical consequence of phase four is that during the animation the user is looking at static images, not working UI. Text inside a snapshot cannot be selected. Buttons cannot be clicked. Video is frozen on a frame. This is why transitions should be short — the platform default of 250ms is a deliberate choice, not an arbitrary one — and why a two-second cinematic page morph is a usability regression dressed as polish.
The four pseudo-elements and what each is for
The overlay tree is generated on the document root, so all four selectors are written unattached — ::view-transition, not html::view-transition. Each of the lower three takes a name argument, and * matches all of them.
::view-transition is the overlay container. It sits above every stacking context on the page and covers the viewport. Style it when you need something behind or across the whole transition: a background colour that shows through while both snapshots are partly transparent, or an opacity ceiling on the entire effect.
::view-transition-group(name) is the positioned box for one captured element. This is the pseudo-element that carries the geometry: the browser gives it an animation interpolating width, height, and transform from the old measurements to the new ones. If a thing moves or resizes during a transition, the group is what moves and resizes. It is also where you set animation-duration when you want to retime a morph.
::view-transition-image-pair(name) is a wrapper with isolation: isolate around the two snapshots. You rarely target it directly. Its one real job is establishing an isolated blending context so the old and new images can composite against each other with mix-blend-mode rather than against the page behind them.
::view-transition-old(name) and ::view-transition-new(name) are the two snapshots — replaced elements whose content is the captured image. These are the ones you attach custom @keyframes to when you want something other than a fade. They both live inside the pair, absolutely positioned, so they overlap by default.
Because they are replaced elements, object-fit and object-position apply to them, which matters whenever the old and new captures have different aspect ratios. The default object-fit: fill is what produces the familiar "text squashes as the box grows" artifact on a shared-element morph; switching to object-fit: cover on both snapshots usually reads better.
Syntax and parameters
| Token | Accepted values | Default |
|---|---|---|
view-transition-name | none, a custom ident, or match-element | none |
view-transition-class | none or one or more custom idents | none |
@view-transition { navigation: … } | auto, none | none (no rule = no cross-document transition) |
::view-transition-group(x) | x = a name, or * | — |
::view-transition-old(x) / -new(x) | x = a name, or * | — |
| Default group animation | interpolates width, height, transform | 250ms ease |
| Default old snapshot animation | opacity: 1 → 0 | 250ms ease |
| Default new snapshot animation | opacity: 0 → 1 | 250ms ease |
view-transition-name on root | implicit name root on :root | root |
Two entries deserve a note. view-transition-class is the escape hatch for styling many names at once: give twelve cards the same class and you can retime all twelve groups with a single ::view-transition-group(.card) rule instead of twelve selectors. And match-element is a newer value that asks the engine to derive a stable identity per element rather than making you invent unique idents — useful, but its availability is narrower than the rest of the API, so verify before relying on it.
Step by step: from no CSS to a controlled transition
Step 1 — trigger a transition and take the default
The entry point for a transition inside a single document is document.startViewTransition(), which takes a callback that performs the DOM change. This is the one piece that is not CSS, and it is deliberately tiny.
<button id="toggle" type="button">Switch view</button>
<main id="panel" class="grid">
<article class="item">Alpha</article>
<article class="item">Beta</article>
</main>
<script>
const btn = document.getElementById('toggle');
const panel = document.getElementById('panel');
btn.addEventListener('click', () => {
// Guard so unsupported browsers still get the state change, just with no animation.
if (!document.startViewTransition) {
panel.classList.toggle('list');
return;
}
document.startViewTransition(() => {
panel.classList.toggle('list');
});
});
</script>
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.grid.list { grid-template-columns: 1fr; }
.item { padding: 1rem; border: 1px solid currentColor; border-radius: 10px; }
With no view-transition CSS at all, this already animates: the browser cross-fades the whole page between the two-column and one-column renderings. The demo below plays the exact opacity curves that default produces.
Step 2 — retime the default cross-fade
The default animations are ordinary CSS animations on ordinary pseudo-elements, so overriding their duration is just a declaration.
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 400ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
Note that you are not re-declaring animation-name. Leaving the user-agent keyframes in place and adjusting only duration and easing is the lightest possible intervention, and it is often all a project needs.
Step 3 — replace the animation entirely
Declare your own keyframes and assign them to the two snapshots. Because both snapshots are stacked in the same pair, the old one should generally be pushed out of the way as the new one arrives.
@keyframes vt-slide-out-left {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(-30%); }
}
@keyframes vt-slide-in-right {
from { opacity: 0; transform: translateX(30%); }
to { opacity: 1; transform: translateX(0); }
}
::view-transition-old(root) {
animation: vt-slide-out-left 300ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
::view-transition-new(root) {
animation: vt-slide-in-right 300ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
The step-by-step mechanics of writing these overrides, including what happens when the two durations disagree, are worked through on same-document view transitions.
Step 4 — name an element so it animates on its own
So far the entire page has been one snapshot. Assigning view-transition-name to an element lifts it out of the root capture and gives it its own group, image pair, and snapshot pair.
.item--featured {
view-transition-name: featured-card;
}
That single declaration is enough for the browser to measure the element before and after, and to generate a group animation interpolating position and size between the two measurements. If the element occupies a different rectangle on either side of the DOM change, it appears to travel and grow.
Step 5 — extend the transition across a page load
Everything above happens inside one document. To make a transition survive a navigation between two same-origin pages, both documents opt in with an at-rule:
@view-transition {
navigation: auto;
}
Put that in the stylesheet of the outgoing page and the incoming page. When the user follows a same-origin link, the browser captures the old document, waits for the new one, captures it, and runs the same pseudo-element machinery across the boundary. Names match across documents, so an element named hero-image on a list page and an element named hero-image on a detail page will morph into each other with no script at all.
The navigation: auto value means "transition on navigations the browser considers appropriate" — which excludes cross-origin navigations, and excludes reloads. navigation: none explicitly opts out, useful for overriding a site-wide rule on a particular page.
An annotated production example: a filterable card list
This is a realistic component: a card grid where activating a filter changes which cards are visible. Each card is named so it slides to its new slot rather than the whole grid cross-fading.
<div class="toolbar">
<button type="button" data-filter="all" class="chip">All</button>
<button type="button" data-filter="design" class="chip">Design</button>
</div>
<ul class="cards">
<li class="card" data-tag="design" style="view-transition-name: card-1">
<h3>Colour systems</h3>
<p>Token naming for themeable palettes.</p>
</li>
<li class="card" data-tag="code" style="view-transition-name: card-2">
<h3>Layout primitives</h3>
<p>Composable spacing and alignment.</p>
</li>
<li class="card" data-tag="design" style="view-transition-name: card-3">
<h3>Motion tokens</h3>
<p>Durations and easing as custom properties.</p>
</li>
</ul>
.cards {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
}
.card {
padding: 1rem;
border: 1px solid currentColor;
border-radius: 12px;
/* Grouped so one rule retimes every card's group animation. */
view-transition-class: card;
}
.card[hidden] { display: none; }
/* The group carries position and size. Slow it slightly so the travel reads. */
::view-transition-group(.card) {
animation-duration: 320ms;
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}
/* Cards that leave should shrink away rather than just fading in place. */
@keyframes card-exit {
to { opacity: 0; transform: scale(0.92); }
}
::view-transition-old(.card) {
animation: card-exit 200ms ease-in both;
}
/* Cards that arrive fade up from slightly below. */
@keyframes card-enter {
from { opacity: 0; transform: translateY(8px); }
}
::view-transition-new(.card) {
animation: card-enter 260ms 60ms ease-out both;
}
/* The overlay itself: keep a solid backdrop so partly transparent
snapshots never reveal the live DOM mid-animation. */
::view-transition {
background-color: Canvas;
}
The inline view-transition-name values are the honest way to write this by hand, and they expose the API's central constraint immediately: each card needs a different name, which means the names have to come from data. Why that is unavoidable, and what it means for list-to-detail navigation, is the subject of view transition names and shared elements.
Grid layouts like this one respond well to being paired with container-driven sizing; see building responsive cards with container queries for the layout side of the same component.
Performance and accessibility
Snapshot cost scales with named elements. Each name means an additional texture the compositor allocates, uploads, and animates. A dozen named cards is fine. Two hundred named rows in a virtualised table will stutter on mid-range hardware, and the stutter arrives during capture — before any animation has started — so it reads as an input delay rather than a dropped frame. Name the few things that carry meaning, not everything on screen.
The animations are compositor-friendly by construction. The default group animation interpolates transform, width, and height; the snapshot animations interpolate opacity. Snapshots are already textures, so scaling them costs the GPU almost nothing — this is one of the rare cases where animating something size-like is cheap. will-change is unnecessary and counterproductive here, because the browser has already promoted these to their own layers; the reasoning behind that is laid out in will-change and the compositor thread.
Rendering is suspended during capture. If the DOM change in your callback is expensive — a synchronous layout of a thousand rows, a blocking data parse — the page is frozen for that entire time and the transition looks broken. Keep the callback to the smallest DOM mutation that produces the new state.
Accessibility is not automatic. The snapshot overlay is inert and unreadable by assistive technology, which is correct — screen reader users are told about the new DOM, not the animation. But focus is not managed for you. When a transition swaps a whole view, move focus deliberately to the new content, or keyboard users land nowhere. And large-area motion is exactly the category WCAG 2.3.3 (Animation from Interactions) targets, which is why the reduced-motion story here is more nuanced than animation: none — see view transitions with reduced motion and the broader recipes in reducing motion preferences in CSS.
DevTools debugging workflow
Because the pseudo-elements exist for a few hundred milliseconds and then vanish, the ordinary inspect-and-tweak loop does not work. Use the animation tooling instead.
- Chrome/Edge — Animations panel. Open DevTools, press
Ctrl/Cmd+Shift+P, run "Show Animations". Trigger the transition. The panel captures every animation in the group, including the user-agent ones, and lists them by pseudo-element name. Clicking one reveals its keyframes and duration. - Slow it down. In the same panel set playback speed to 25% or 10%, then re-trigger. At 10% a 250ms transition runs for two and a half seconds — long enough to see whether a snapshot is squashing, whether a group is travelling to the wrong rectangle, or whether an old snapshot is still opaque when it should be gone.
- Freeze it in the Elements tree. With the animation paused, the
::view-transitionsubtree is visible under the document root in Elements. You can select::view-transition-group(name)and read its computedwidth,height, andtransformat that instant — this is the fastest way to confirm the browser measured what you expected. - Check for duplicate names. If a transition silently does nothing, open the console. Engines log a warning when two elements share a
view-transition-namein the same snapshot, and that condition aborts the whole transition rather than degrading it. - Safari — Web Inspector. The Elements tree exposes the same pseudo-element subtree while paused, though the dedicated animation timeline is less detailed than Chrome's. Testing there matters, because Safari's snapshot rasterisation of text at fractional scales differs visibly from Chromium's.
Browser compatibility
| Feature | Support as of mid-2026 | Notes |
|---|---|---|
| Same-document view transitions | Chrome 111+, Edge 111+, Firefox 144+, Safari 18+ | Available in every current engine |
::view-transition-* pseudo-elements | Ships with same-document support | Same availability as above |
view-transition-name | Ships with same-document support | Custom ident values |
Cross-document @view-transition | Chrome 126+, Edge 126+, Safari 18.2+ | Not supported in Firefox; the one remaining gap |
view-transition-class | Chromium only at the time of writing | Selector convenience; the per-name selectors are the fallback |
Read the table as two separate stories. Same-document transitions are done: Chromium shipped them in 111, Safari in 18, and Firefox in 144, so the feature is interoperable and the only population without it is browser versions older than those. The cross-document half is not: @view-transition is Chromium 126 and Safari 18.2, and Firefox has not shipped it. That is now the single meaningful gap in this section, and it is why a multi-page navigation must still work perfectly with no transition at all — describe cross-document transitions in a project as an enhancement some browsers apply, and never let a navigation depend on one.
There is no useful @supports test for the JavaScript entry point, so feature-detect in script with if (!document.startViewTransition). For the at-rule you can use @supports at-rule(@view-transition) in engines that implement the at-rule() test, but the safer pattern is simply to let unsupporting browsers ignore the unknown at-rule, which they do silently and harmlessly.
Common pitfalls
| Pitfall | Cause | Resolution |
|---|---|---|
| Transition aborts with nothing happening | Two elements share one view-transition-name in a single snapshot | Names must be unique per capture; derive them from record IDs and clear them when off-screen |
| Text distorts as a box grows | Snapshots are replaced elements defaulting to object-fit: fill | Set object-fit: cover on both -old() and -new(), or animate a wrapper instead of the text |
| Sticky or fixed header jumps to the top | Snapshots are captured relative to the viewport and painted in the overlay, losing fixed positioning | Give the header its own view-transition-name so it becomes its own group and stays put |
| Page appears frozen for a beat before animating | Expensive DOM work inside the startViewTransition callback while rendering is suspended | Move data fetching and parsing outside the callback; mutate only |
Custom keyframes on -old() cause a flash at the end | animation-fill-mode not set, so the snapshot snaps back to its start value | Add both (or forwards) to the animation shorthand |
FAQ
Is the View Transitions API a CSS feature or a JavaScript feature?
Both. A same-document transition is started from JavaScript with document.startViewTransition(), and cross-document transitions are opted into with the @view-transition at-rule. Everything about how the transition looks — timing, easing, keyframes, which elements move independently — is CSS applied to generated pseudo-elements.
What does the browser actually animate during a view transition? Flat snapshot images, not live DOM. The browser captures the old rendering, applies your DOM change, captures the new rendering, then animates replaced elements built from those two captures in a separate overlay tree above the page.
Why does my whole page cross-fade instead of just the element I named?
Because everything without an explicit view-transition-name is captured together as a single root snapshot. Naming an element pulls it out of the root snapshot into its own group so it can animate independently.
Do I need to write any CSS for a view transition to work? No. With no CSS the user agent applies a 250ms cross-fade to the root snapshot pair. You write CSS only when you want different timing, different easing, or independent motion for named elements.
Which browsers support view transitions in mid-2026?
Same-document view transitions ship in Chrome and Edge 111+, Safari 18+, and Firefox 144+, so they are available in every current engine. The cross-document @view-transition rule is narrower: Chrome and Edge 126+ and Safari 18.2+, with no Firefox support yet.
Related
- Same-document view transitions — the CSS you write to replace the default cross-fade.
- View transition names and shared elements — morphing a thumbnail into a hero, and the uniqueness rule that constrains it.
- View transitions with reduced motion — swapping travel for a fade instead of switching the transition off.
- Keyframe Animation Patterns — the
@keyframestechniques you attach to the snapshot pseudo-elements. - Scroll-driven animations — the other recent API that hands animation timing to the browser.
- Building responsive cards with container queries — the layout side of the card grid used in this guide.
Related articles
More pages in the same section.