How @view-transition Works Under the Hood
The View Transitions API solves a fundamental rendering problem: how to interpolate between two DOM states without forcing the main thread to calculate layout for every in-between frame. By capturing GPU bitmaps of the old and new states, the browser can hand the crossfade entirely to the compositor thread — the same thread that powers scroll-driven animation timelines — leaving the main thread free throughout the animation window. Understanding the snapshot lifecycle, the pseudo-element hierarchy, and the interaction with the browser’s rendering pipeline is what separates a reliable implementation from one that leaks GPU memory or produces stale layer artifacts.
Pages in this section
- View Transition API vs FLIP Technique Comparison
- Same-Document vs Cross-Document View Transitions — how
startViewTransition()and the@view-transitionrule differ in lifecycle, browser support, and setup
Syntax reference
The core API surface is small. Understanding each entry point and its timing contract is essential for correct usage.
// Initiate a transition
const transition = document.startViewTransition(callback);
// Promises exposed on ViewTransition
await transition.ready; // pseudo-elements created; safe to attach WAAPI
await transition.finished; // all animations done; bitmaps released
transition.skipTransition(); // abort — collapses to end state immediately
/* Per-element snapshotting */
.card { view-transition-name: product-card; } /* must be unique at capture time */
.card { view-transition-name: none; } /* opt out — no bitmap allocated */
/* CSS-side pseudo-element targeting */
::view-transition-old(root) { /* outgoing full-page bitmap */ }
::view-transition-new(root) { /* incoming full-page bitmap */ }
::view-transition-old(product-card) { /* outgoing per-element bitmap */ }
::view-transition-new(product-card) { /* incoming per-element bitmap */ }
::view-transition-group(product-card){ /* handles geometric interpolation */ }
::view-transition-image-pair(product-card) { /* isolation context */ }
view-transition-name accepts any <custom-ident> except none and auto. The keyword none is the opt-out value.
Minimal working example
A same-document transition with a named element morph requires only three additions beyond normal DOM mutation:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>View Transition Demo</title>
<style>
.card {
/* 1. Name the element so the browser snapshots it */
view-transition-name: product-card;
width: 200px;
padding: 1rem;
background: Canvas;
border: 1px solid currentColor;
}
/* 3. Override the default crossfade with a morph */
::view-transition-old(product-card) {
animation: vt-out 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
::view-transition-new(product-card) {
animation: vt-in 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes vt-out {
to { opacity: 0; transform: translateY(-8px) scale(0.97); }
}
@keyframes vt-in {
from { opacity: 0; transform: translateY(8px) scale(0.97); }
}
</style>
</head>
<body>
<div class="card" id="card">State A</div>
<button onclick="swap()">Swap</button>
<script>
function swap() {
// 2. Wrap the mutation in startViewTransition
if (!document.startViewTransition) {
document.getElementById('card').textContent = 'State B';
return;
}
document.startViewTransition(() => {
document.getElementById('card').textContent = 'State B';
});
}
</script>
</body>
</html>
No build step, no dependencies. The ::view-transition-old and ::view-transition-new pseudo-elements exist only for the duration of the animation — they are not part of the normal DOM tree.
Snapshot lifecycle and timeline scoping
The four-phase lifecycle illustrated above maps directly onto the browser’s rendering pipeline:
Phase 1 — old-state capture. The browser calls into the compositor to rasterize all elements that carry view-transition-name. The result is a set of GPU bitmaps. The paint is synchronous from the browser’s internal perspective; no intermediate frames are shown to the user.
Phase 2 — DOM mutation. Your callback runs. If it returns a Promise, the browser waits for resolution. The page appears frozen (the old bitmap is held onscreen) while the callback executes.
Phase 3 — new-state capture. The browser rasterizes the updated DOM into a second set of GPU bitmaps.
Phase 4 — compositor animation. Both bitmap sets are promoted to ::view-transition-old() and ::view-transition-new() pseudo-elements under a ::view-transition overlay. The compositor animates them. The main thread is idle.
transition.ready resolves between phase 3 and phase 4 — at the moment the pseudo-elements exist but before any animation frame has played. This is the correct point to attach Web Animations API keyframes:
const transition = document.startViewTransition(() => {
document.querySelector('.card').classList.toggle('expanded');
});
await transition.ready;
// Safe to animate pseudo-elements via WAAPI here
document.querySelector('::view-transition-new(product-card)')
?.animate(
[{ clipPath: 'inset(0 100% 0 0)' }, { clipPath: 'inset(0 0% 0 0)' }],
{ duration: 400, easing: 'ease-out', fill: 'forwards' }
);
Compositor-safe properties
Properties that run on the compositor thread cost zero main-thread budget during playback. Properties that force main-thread recalculation block the 16 ms frame budget and can cause jank.
| Property | Compositor-safe? | Notes |
|---|---|---|
opacity |
Yes | Safe on both ::view-transition-old/new and named elements |
transform |
Yes | Safe; covers translate, scale, rotate, skew |
filter |
Yes | Safe when used on the pseudo-elements; avoid on the source element during capture |
clip-path |
Partial | Simple shapes (inset, circle) compositor-safe in Chromium 117+; complex paths force main thread |
width / height |
No | Forces layout recalculation; use transform: scale() instead |
background-color |
No | Forces paint; use opacity on a coloured layer |
border-radius |
Partial | On ::view-transition-group it is compositor-safe; on source elements it may force repaint |
mix-blend-mode |
No | Forces compositing context promotion and main-thread paint |
Apply will-change: transform, opacity to ::view-transition-group selectors when you know a named element will animate position and size simultaneously:
::view-transition-group(product-card) {
will-change: transform, opacity;
}
Remove will-change after transition.finished to release the compositor layer.
Common implementation patterns
Pattern 1: Full-page slide transition (SPA navigation)
Pair the @view-transition at-rule with Navigation API for automatic same-document slide transitions. This does not require document.startViewTransition() to be called manually — the browser handles it.
@view-transition {
navigation: auto;
}
/* Slide new page in from the right */
::view-transition-new(root) {
animation: slide-in 0.4s cubic-bezier(0.4, 0, 0.2, 1) both;
}
::view-transition-old(root) {
animation: slide-out 0.4s cubic-bezier(0.4, 0, 0.2, 1) both;
}
@keyframes slide-in {
from { transform: translateX(100%); }
}
@keyframes slide-out {
to { transform: translateX(-40%); opacity: 0; }
}
The navigation: auto value opts all same-origin navigate events into the view-transition lifecycle automatically. You can intercept specific routes with the Navigation API’s navigate event handler to vary the animation per route.
Pattern 2: Shared-element morph (list to detail)
/* List page */
.product-image { view-transition-name: hero-image; }
/* Detail page */
.hero-image { view-transition-name: hero-image; }
/* The browser auto-detects the same name in old and new states and morphs */
::view-transition-group(hero-image) {
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
animation-duration: 500ms;
}
The ::view-transition-group pseudo-element handles the geometry interpolation (position and size) automatically. You only need to ensure both the list item and the detail image carry the same view-transition-name at capture time.
Pattern 3: Memory-optimised viewport-bound assignment
Every view-transition-name allocates a GPU bitmap. For list pages with many items, restrict named snapshots to elements near the viewport:
function refreshTransitionTargets() {
const vpH = window.innerHeight;
document.querySelectorAll('[data-transition-id]').forEach(el => {
const { top, bottom } = el.getBoundingClientRect();
/* snapshot elements within one viewport height of the visible area */
const near = top < vpH * 2 && bottom > -vpH;
el.style.viewTransitionName = near
? `item-${el.dataset.transitionId}`
: 'none'; /* none = no bitmap allocated */
});
}
/* Debounce via rAF to avoid layout thrashing */
let rafId;
window.addEventListener('scroll', () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(refreshTransitionTargets);
}, { passive: true });
refreshTransitionTargets();
Pattern 4: Scroll-threshold triggered transitions
Scroll-driven animation timelines can act as a trigger for view transitions. Fire document.startViewTransition() when a scroll boundary is crossed using an IntersectionObserver:
const sentinel = document.querySelector('.section-sentinel');
new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting || !document.startViewTransition) return;
document.startViewTransition(() => {
document.querySelector('.panel').classList.toggle('revealed');
});
}, { threshold: 0.5 }).observe(sentinel);
Avoid attaching animation-timeline: scroll() directly to the same element that carries view-transition-name — the scroll-timeline binding may conflict with the snapshot phase, producing a stale bitmap that reflects an intermediate scroll-animation state rather than the intended endpoint.
Browser support and @supports guard
| Feature | Chrome | Edge | Safari | Firefox |
|---|---|---|---|---|
document.startViewTransition() |
111 | 111 | 18.0 | 129 |
@view-transition { navigation: auto } |
126 | 126 | 18.2 | 130 |
| Cross-document view transitions | 126 | 126 | 18.2 | 130 |
::view-transition-group targeting |
111 | 111 | 18.0 | 129 |
view-transition-class (MV2) |
125 | 125 | Not yet | Not yet |
Wrap CSS rules that depend on pseudo-elements being present:
@supports (view-transition-name: test) {
.card {
view-transition-name: product-card;
}
::view-transition-old(product-card) {
animation: vt-out 0.35s ease forwards;
}
::view-transition-new(product-card) {
animation: vt-in 0.35s ease forwards;
}
}
Wrap JS calls to guard against browsers that have not shipped the API:
async function navigate(url) {
if (!document.startViewTransition) {
/* graceful fallback — plain navigation */
window.location.href = url;
return;
}
const transition = document.startViewTransition(async () => {
const html = await fetch(url).then(r => r.text());
document.body.innerHTML = new DOMParser()
.parseFromString(html, 'text/html')
.body.innerHTML;
});
await transition.finished;
}
Always respect prefers-reduced-motion. The default crossfade is a short opacity animation and is usually acceptable, but morphs and slides are not:
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.01ms;
}
}
Or skip entirely in JavaScript:
const transition = document.startViewTransition(mutate);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
transition.skipTransition();
}
Gotchas and failure modes
- Duplicate
view-transition-nameat capture time. If two elements share the same name when the browser rasterizes, it silently drops both named captures and falls back to a root crossfade for those elements. Audit with a utility before each transition fires:
function assertUniqueTransitionNames() {
const names = [...document.querySelectorAll('[style*="view-transition-name"]')]
.map(el => el.style.viewTransitionName)
.filter(n => n && n !== 'none');
const dupes = names.filter((n, i) => names.indexOf(n) !== i);
if (dupes.length) console.warn('Duplicate view-transition-name:', dupes);
}
-
Stale bitmap from async callback. If your callback returns a long-running Promise, the page is frozen (old bitmap shown) until it resolves. Users see a stuck frame. Cap callback duration; split network requests away from the mutation or use optimistic UI.
-
GPU memory leak from unreleased names. Removing an element from the DOM does not automatically release its bitmap if the element was snapshotted but
transition.finishedwas never awaited. Alwaysawait transition.finishedand then removeview-transition-namefrom elements that scroll off-screen. -
will-changeleft on after the transition. Settingwill-change: transformon source elements to pre-warm compositor layers is effective but must be removed aftertransition.finished. Leaving it on promotes every affected element to its own compositor layer permanently, increasing GPU memory pressure. -
::view-transition-old/newinheritingfont-sizefrom host. The pseudo-elements live in the::view-transitiontop-layer overlay. Some browser versions inheritfont-sizefrom the root, which can affect layout-based bitmaps. Reset explicitly:::view-transition-image-pair(*) { isolation: isolate; } -
mix-blend-modeon source element breaking the bitmap. Elements withmix-blend-modeother thannormalforce a new stacking context. The bitmap capture may not match the rendered appearance because the blend operation requires knowledge of what is underneath — which is unavailable after isolation. Useopacitytransitions instead, or accept that blended elements will snap rather than morph.
DevTools profiling workflow
- Open DevTools → Layers panel. Enable Show layer borders and Paint flashing.
- Trigger a transition and verify that each
view-transition-nameproduces an isolated compositing layer in the layer tree. - Switch to Performance panel. Record a transition. Filter for
Recalculate StyleandPaintevents during the animation window — these should show 0 ms duration if only compositor-safe properties are animated. - Open Memory panel. Take a heap snapshot before and after a series of transitions. If retained size climbs linearly, old
::view-transition-oldlayers are not being released. Verify thattransition.finishedis being awaited and thatview-transition-nameis removed from off-viewport elements. - In the Elements panel, trigger a transition and expand the
#top-layernode — the::view-transitiontree will appear live, letting you inspect which pseudo-elements are generated and their computed styles.
Performance checklist
- Use
view-transition-nameonly on elements the user will actually see animate; opt out everything else withview-transition-name: none - Limit simultaneous named elements to under 20 on any given transition; profile above that threshold
- Animate only
transformandopacityon::view-transition-old/new; avoidwidth,height,top,left - Apply
will-change: transform, opacityto::view-transition-groupselectors during the transition, then remove it aftertransition.finished - Assign
view-transition-namevalues lazily (only to in-viewport elements) for list-heavy pages - Always
await transition.finishedbefore performing follow-up DOM work to avoid tearing - Always implement a
prefers-reduced-motionguard — skip or shorten transitions for users who need it
Up: Core Animation Fundamentals & Browser Mechanics
Frequently Asked Questions
Why does my transition run but nothing appears to move?
Because both captured states were identical. The API takes a snapshot, runs your callback, takes a second snapshot, and interpolates between them — so if the DOM had not actually changed by the time the callback returned, the two snapshots match and the interpolation has nothing to express.
The usual cause is an asynchronous update: a framework state change that schedules a re-render rather than performing it, or an await inside the callback that returns control before the DOM is final. Frameworks generally provide a way to flush the update synchronously for exactly this reason, and their router integrations exist largely to do it for you.
What happens if the same name appears on two elements?
The transition is skipped. The specification requires each view-transition-name to be unique among rendered elements at capture time, and an ambiguous name is treated as an error condition rather than resolved by picking one. The visible result is a hard cut with no console output, which makes it one of the harder failures to diagnose from symptoms alone.
The pattern that avoids it is assigning the name at interaction time to the specific element involved, and clearing it once the transition finishes. Names assigned statically to a repeated component are the reliable way to end up with two.
How many named elements is too many?
Each name costs a snapshot and a compositing layer on both sides of the transition, so the cost scales with the count and with the size of each element. Ten to fifteen names is a reasonable ceiling for a single transition on desktop, and fewer on mobile where GPU memory is tighter.
In practice, good transitions rarely want many. The effect works because one or two elements maintain identity across the change while everything else crossfades; naming a dozen elements produces a busy, incoherent movement in addition to the memory cost.
Can I animate properties other than position and size?
The generated pseudo-elements are ordinary animation targets, so you can animate anything they support — including opacity, filter, and custom easing on the group’s transform. What you cannot do is reach inside the snapshots, because they are bitmaps rather than live DOM. An effect that needs to animate something within the element during the transition needs that element to be live, which means animating it outside the transition rather than through it.
This is also why text inside a morphing element resamples rather than reflowing: it is a picture of text, not text.
What the snapshots actually contain
A captured snapshot is a rasterised image of the element as it was painted, plus the geometry it occupied. That single sentence explains most of the API’s behaviour that surprises people: text inside a snapshot is pixels rather than glyphs, so it resamples rather than reflowing; a video inside a snapshot is a still frame; and an element that was clipped or partially off-screen is captured as it was rendered, not as it would be if it were fully visible.
It also explains the memory profile. Each named element produces two rasters, sized by its painted area and the device pixel ratio, held for the duration of the transition. That is why the count of named elements matters more than the complexity of what is inside them, and why a full-viewport name is dramatically more expensive than several small ones.