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
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