SPA Page Swap Animations with the View Transitions API
The View Transitions API eliminates the need for JavaScript transition libraries by capturing compositor-layer snapshots of the DOM before and after a mutation, then crossfading or morphing them entirely off the main thread. For SPA route changes, document.startViewTransition() wraps your router’s DOM-update callback, generates ::view-transition-old and ::view-transition-new pseudo-elements, and hands control to CSS — the same compositor-thread model that powers pure-CSS parallax with animation-timeline.
The pages below cover the full production workflow: API syntax, scroll-timeline synchronization after navigation, compositor-safe property choices, browser support guards, and the accessibility obligations that apply to every route change.
Pages in this section
- Implementing View Transitions for React Router — router-aware
startViewTransitionintegration, deferred loaders, and Suspense boundary timing
Transition lifecycle diagram
Syntax reference
document.startViewTransition() accepts a synchronous or async callback. It returns a ViewTransition object with three promises:
// Signature
const transition = document.startViewTransition(callback);
// Promises on the returned object
transition.ready; // resolves when pseudo-elements are created; use for JS-driven animations
transition.finished; // resolves when animation is complete and pseudo-elements removed
transition.updateCallbackDone; // resolves when callback finishes (before animation starts)
// Imperative abort
transition.skipTransition(); // skips to end state, cancels animation
CSS pseudo-elements generated per named element:
/* Default root capture */
::view-transition-old(root) { /* outgoing snapshot */ }
::view-transition-new(root) { /* incoming snapshot */ }
/* Named element capture */
::view-transition-old(hero-image) { }
::view-transition-new(hero-image) { }
/* Group wrapper (controls positioning) */
::view-transition-group(hero-image) { }
Assign view-transition-name in CSS to opt individual elements into the named-capture path:
.hero-image {
view-transition-name: hero-image;
contain: layout; /* required — name must be unique in the DOM */
}
Property defaults relevant to the pseudo-elements: animation-duration defaults to 0.25s; animation-timing-function defaults to ease; mix-blend-mode on ::view-transition-old defaults to normal.
Minimal working example
A self-contained SPA swap with feature detection and motion guard — no build tools, no framework:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
/* Outgoing page slides left and fades */
::view-transition-old(root) {
animation: page-out 0.28s ease-in forwards;
}
/* Incoming page slides in from right and fades */
::view-transition-new(root) {
animation: page-in 0.28s ease-out forwards;
}
@keyframes page-out {
to { opacity: 0; transform: translateX(-24px); }
}
@keyframes page-in {
from { opacity: 0; transform: translateX(24px); }
}
/* Kill all motion for users who request it */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}
main { padding: 2rem; font-family: system-ui, sans-serif; }
</style>
</head>
<body>
<nav>
<a href="#" onclick="go('page-a'); return false;">Page A</a>
<a href="#" onclick="go('page-b'); return false;">Page B</a>
</nav>
<main id="content">
<h1>Page A</h1>
<p>Initial content.</p>
</main>
<div id="route-announcer" aria-live="polite" aria-atomic="true"
style="position:absolute;clip:rect(0,0,0,0);width:1px;height:1px;overflow:hidden;"></div>
<script>
const pages = {
'page-a': '<h1>Page A</h1><p>Content for page A.</p>',
'page-b': '<h1>Page B</h1><p>Content for page B.</p>',
};
async function go(id) {
if (!document.startViewTransition) {
// Graceful degradation: plain swap, no animation
document.getElementById('content').innerHTML = pages[id];
return;
}
const transition = document.startViewTransition(() => {
document.getElementById('content').innerHTML = pages[id];
document.title = id.replace('-', ' ').replace(/\b\w/g, c => c.toUpperCase());
});
await transition.finished;
// Focus management — place focus on the new heading
const h1 = document.querySelector('main h1');
if (h1) {
h1.setAttribute('tabindex', '-1');
h1.focus({ preventScroll: true });
h1.removeAttribute('tabindex');
}
// Screen-reader announcement
const announcer = document.getElementById('route-announcer');
if (announcer) announcer.textContent = `Navigated to ${document.title}`;
}
</script>
</body>
</html>
Scroll-timeline synchronization after navigation
When a route change fires, any active animation-timeline: scroll() binding on the incoming page may initialize before the browser’s scroll position settles, causing parallax layers to jump visibly. The fix is two-part: disable automatic scroll restoration and defer any scroll position write until after the transition animation completes.
// Set once at app initialization — before any navigation
window.history.scrollRestoration = 'manual';
async function navigateWithScrollRestore(url, savedScrollY) {
const transition = document.startViewTransition(() => updateDOM(url));
// Wait for animation to finish before touching scroll position
await transition.finished;
if (savedScrollY != null) {
// 'instant' prevents a visible scroll jump from triggering scroll-driven animations
window.scrollTo({ top: savedScrollY, behavior: 'instant' });
}
}
For a thorough explanation of the animation-timeline: view() scoping model that parallax layers depend on, see parallax effects with pure CSS and animation-timeline.
When using named scroll timelines (scroll-timeline-name) on elements inside the route container, those timelines are torn down when the container is removed from the DOM during the callback. Recreate them after transition.updateCallbackDone resolves if you need continuity across routes.
Compositor-safe properties
Only animate properties that the compositor can handle without triggering layout or paint. Animating width, height, top, or margin on the pseudo-elements forces synchronous layout recalculation every frame, shattering the 150–300ms animation budget.
| Property | Compositor-thread | Forces layout | Notes |
|---|---|---|---|
opacity |
Yes | No | Ideal for crossfade transitions |
transform (translate, scale, rotate) |
Yes | No | Preferred for movement and morphing |
filter (blur, brightness) |
Yes* | No | *Promoted to compositor only when on a layer |
clip-path |
Partial | No† | †Simple shapes OK; complex paths may rasterize |
width / height |
No | Yes | Never animate on ::view-transition pseudo-elements |
background-color |
No | No | Triggers paint; avoid in tight loops |
border-radius |
Partial | No | Rasterized when combined with transforms |
Apply contain and will-change to the route wrapper so the browser can promote it to its own compositor layer before the transition starts:
.route-container {
contain: layout style paint;
will-change: transform, opacity;
}
contain: layout style paint establishes a formatting-context boundary. The browser skips style invalidation for ancestor elements during the transition, keeping frame times well under 16ms. Remove will-change from elements that are not actively transitioning — it consumes GPU memory continuously.
Common implementation patterns
Pattern 1: Directional slide based on navigation direction
Detect whether the user is navigating forward or back, and flip the animation direction to match:
let navDirection = 'forward'; // track in router state
async function navigate(url, direction = 'forward') {
navDirection = direction;
document.documentElement.dataset.navDirection = direction;
const transition = document.startViewTransition(() => updateDOM(url));
await transition.finished;
delete document.documentElement.dataset.navDirection;
}
/* Default: forward (right → center, center → left) */
::view-transition-old(root) {
animation: slide-out-left 0.3s ease-in forwards;
}
::view-transition-new(root) {
animation: slide-in-right 0.3s ease-out forwards;
}
/* Back navigation: reverse the directions */
[data-nav-direction="back"] ::view-transition-old(root) {
animation: slide-out-right 0.3s ease-in forwards;
}
[data-nav-direction="back"] ::view-transition-new(root) {
animation: slide-in-left 0.3s ease-out forwards;
}
@keyframes slide-out-left { to { transform: translateX(-40px); opacity: 0; } }
@keyframes slide-in-right { from { transform: translateX(40px); opacity: 0; } }
@keyframes slide-out-right { to { transform: translateX(40px); opacity: 0; } }
@keyframes slide-in-left { from { transform: translateX(-40px); opacity: 0; } }
Pattern 2: Shared element promotion (hero transition)
Promote a specific element — a card image, a title — into its own capture so it morphs independently while the rest of the page crossfades. This technique is the foundation for cross-route element morphing.
/* On the list route */
.card-thumbnail[data-id="42"] {
view-transition-name: hero-42;
contain: layout;
}
/* On the detail route — same name, different element */
.detail-hero[data-id="42"] {
view-transition-name: hero-42;
contain: layout;
}
async function openDetail(id) {
// Set the name only for the duration of the transition
document.querySelector(`.card-thumbnail[data-id="${id}"]`)
.style.viewTransitionName = `hero-${id}`;
const transition = document.startViewTransition(() => renderDetail(id));
await transition.ready; // pseudo-elements now exist
// Optionally drive with WAAPI for spring physics
document.querySelector(`::view-transition-group(hero-${id})`)
?.animate({ /* custom keyframes */ }, { duration: 400, easing: 'ease-out' });
await transition.finished;
}
Pattern 3: Staggered content reveal post-transition
Use transition.finished as the trigger to start scroll-driven or time-based reveals on the incoming page’s content:
async function navigate(url) {
const transition = document.startViewTransition(() => updateDOM(url));
await transition.finished;
// Trigger section reveals — animation-timeline: view() picks up from here
document.querySelectorAll('.reveal-on-scroll').forEach(el => {
el.classList.add('ready'); // CSS handles the rest via scroll()
});
}
.reveal-on-scroll {
opacity: 0;
transform: translateY(16px);
}
.reveal-on-scroll.ready {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}
@keyframes reveal {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
Pattern 4: Rapid-navigation abort guard
If the user triggers a second navigation before the first transition finishes, the old ViewTransition is automatically aborted and transition.finished rejects with an AbortError. Guard against unhandled rejections:
let activeTransition = null;
async function navigate(url) {
// Cancelling previous transition is automatic — just track for cleanup
activeTransition = document.startViewTransition(() => updateDOM(url));
try {
await activeTransition.finished;
announceNavigation();
} catch (err) {
if (err.name !== 'AbortError') throw err;
// Transition was superseded — the new one will handle announcement
}
}
Browser support and @supports guard
The same-document View Transitions API shipped in Chrome 111 (March 2023) and Safari 18 (September 2024). Firefox 131+ has it behind dom.viewTransitions.enabled. For comprehensive feature detection patterns across all animation APIs, see browser support and progressive enhancement for CSS animations.
| Feature | Chrome | Safari | Firefox | Edge |
|---|---|---|---|---|
document.startViewTransition |
111 (Mar 2023) | 18 (Sep 2024) | 131 (flag) | 111 (Mar 2023) |
view-transition-name (CSS) |
111 | 18 | 131 (flag) | 111 |
::view-transition-* pseudo-elements |
111 | 18 | 131 (flag) | 111 |
@view-transition (MPA / cross-document) |
126 | 18.2 | Not shipped | 126 |
transition.types (typed transitions) |
125 | Not shipped | Not shipped | 125 |
Wrap scroll-driven animation integration in @supports to prevent double-failure on older engines:
/* Scroll-driven reveal only if scroll-timeline is supported */
@supports (animation-timeline: scroll()) {
.reveal-on-scroll.ready {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}
}
/* View transition styles only run when the API is available */
@supports (view-transition-name: none) {
::view-transition-old(root) {
animation: page-out 0.28s ease-in forwards;
}
::view-transition-new(root) {
animation: page-in 0.28s ease-out forwards;
}
}
For respecting prefers-reduced-motion in scroll-driven and view-transition animations, the simplest production pattern is:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
animation-duration: 0.01ms !important;
}
}
Using animation-duration: 0.01ms rather than none preserves the transition.finished resolution path — none can leave promises in a permanently pending state in some engines.
Gotchas and failure modes
-
Duplicate
view-transition-namevalues crash the transition. If two elements in the active DOM carry the same name whenstartViewTransitionfires, the entire transition is skipped — no error is thrown in the console by default. Verify uniqueness withdocument.querySelectorAll('[style*="view-transition-name"]')before triggering, or scope names dynamically per item ID. -
contain: layoutis required alongsideview-transition-name. The spec mandates that any element with aview-transition-namemust establish a containing block. Omitting it produces spec-violating behavior and renders inconsistently across Chrome and Safari. -
Scroll-timeline jumps when
scrollRestorationis left onauto. Withoutwindow.history.scrollRestoration = 'manual', the browser may restore scroll position mid-transition, causinganimation-timeline: scroll()values to change abruptly. Set it before your first navigation. -
transition.finishedrejects on rapid navigation. TheAbortErroris intentional; it signals the transition was superseded. Uncaught rejections surface as unhandled promise errors in the console and can break error-monitoring tools. Always.catch()or try/catchtransition.finished. -
Safari 18 does not support
transition.types. The typed-transition API (document.startViewTransition({ update, types })) that enables conditional CSS targeting viahtml:active-view-transition-type(...)is Chrome 125+ only. Feature-detect with'types' in ViewTransition.prototypebefore using it. -
will-change: transformonbodypromotes the entire page to a new GPU layer, bloating VRAM on mobile devices. Scopewill-changeto.route-containeronly and remove it once the transition finishes by toggling a class.
Performance checklist
Related
- Cross-Route Element Morphing — matching
view-transition-nameacross routes for shared-element morphing - Implementing View Transitions for React Router — router-specific integration, deferred data, and Suspense timing
- Browser Support & Progressive Enhancement —
@supportsguard recipes and feature-detection patterns across all CSS animation APIs - Implementing
prefers-reduced-motion— motion-safe overrides and WCAG compliance for transition animations