Implementing Cross-Document View Transitions in SPAs
Part of View Transitions API Implementation in Modern View Transitions & Scroll APIs.
The problem: jarring snaps during client-side route changes
In a single-page application, navigating between routes causes shared UI elements — a persistent header, a product card, a hero image — to snap abruptly to their new positions rather than animate through them. The browser receives no signal that the “old” page and the “new” page share elements, so it discards the current paint state and redraws from scratch. The result is an Interaction to Next Paint (INP) spike and a broken sense of spatial continuity for the user.
Root cause: the browser cannot correlate snapshots it never captured
The View Transitions API works by capturing two bitmaps — a “before” snapshot and an “after” snapshot — then GPU-interpolating between them. In a native multi-page application the browser handles this automatically at navigation time. In an SPA, the framework typically unmounts the current component tree before mounting the next one, destroying the source element before the browser can freeze its snapshot.
Without the “before” state, the API has nothing to animate from. The ::view-transition-old() pseudo-element is empty, and any view-transition-name assignments on the destroyed elements are lost. Worse, if the framework mutates the DOM synchronously on the main thread during the routing phase, it blocks the browser’s ability to promote the transitioning layers to the compositor — so even a late-captured snapshot runs on the CPU, missing the 16 ms frame budget and dropping frames before the animation starts.
The fix is structural: the DOM swap must happen inside the updateCallback passed to document.startViewTransition(). Only then can the browser capture the “before” state before the swap and the “after” state after it.
The diagram below maps the two execution paths — the broken path where the framework unmounts first, and the correct path where the swap is deferred into the transition callback.
Step-by-step resolution
Step 1 — Feature-detect and gate on reduced-motion preference
Before touching the router, check that the API exists and that the user has not requested reduced motion. The View Transitions API is not universally supported; a synchronous feature check prevents TypeErrors in older browsers and avoids inflicting fast motion on users with vestibular disorders.
function canTransition() {
return (
typeof document.startViewTransition === 'function' &&
!window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
Rendering Impact: main_thread — this runs synchronously before any DOM mutation, so it adds zero frame cost.
Step 2 — Wrap router navigation in the transition callback
The entire route change — including any async data fetch your router performs — must resolve inside the callback passed to startViewTransition. The browser pauses the transition pipeline until the Promise returned by the callback settles, then captures the “after” snapshot.
async function navigateWithTransition(url) {
if (!canTransition()) {
// Fallback: plain navigation with no transition overhead
await router.push(url);
return;
}
const transition = document.startViewTransition(async () => {
// DOM swap happens HERE — browser has already frozen the before-snapshot
await router.push(url);
});
// Await the full animation before firing analytics or focus management
await transition.finished;
}
Rendering Impact: composite — by deferring router.push into the callback, both snapshots are captured cleanly and the cross-fade runs entirely on the GPU compositor thread.
Step 3 — Assign deterministic view-transition-name tokens
Every element that should morph between routes needs an identical view-transition-name value on both the old and new DOM. Names must be unique within the document at snapshot time; duplicate names cause a DOMException that aborts the pipeline. For dynamic content, derive names from stable data attributes.
/* Static shared shell elements */
.site-header { view-transition-name: site-header; }
.site-sidebar { view-transition-name: site-sidebar; }
/* Dynamic elements: name set via JS before startViewTransition is called */
/* element.style.setProperty('view-transition-name', `product-${item.id}`); */
/* Custom morph animation — only transform and opacity are compositor-safe */
@keyframes slide-up-fade-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
::view-transition-new(site-header) {
animation: slide-up-fade-in 0.28s cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Reduced-motion: disable all transition animations */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*),
::view-transition-new(*) {
animation: none;
}
}
Rendering Impact: composite — transform and opacity mutations on ::view-transition-old/new pseudo-elements stay on the GPU compositor layer and do not trigger layout or paint.
Step 4 — Graceful degradation with CSS keyframe fallback
For browsers that do not support document.startViewTransition, apply route-change CSS classes directly to the incoming and outgoing view containers. This preserves a comparable visual rhythm without any JS-controlled transition pipeline.
/* Applied by the router when the old route is leaving */
.route-exit {
animation: fade-out 0.25s ease-in both;
will-change: opacity, transform;
}
/* Applied by the router when the new route is entering */
.route-enter {
animation: fade-in 0.25s ease-out both;
will-change: opacity, transform;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-out {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-8px); }
}
/* Honour reduced-motion for the fallback path too */
@media (prefers-reduced-motion: reduce) {
.route-enter,
.route-exit { animation: none; }
}
Rendering Impact: composite — will-change: opacity, transform promotes the animated containers to their own compositor layers before the animation starts, preventing layout recalculation on every frame.
Verification checklist
Constraints and trade-offs
- Same-origin only. Cross-document transitions in an SPA are same-origin by design; the browser’s security sandbox prevents snapshot sharing across origins. Subdomains require a full-page reload.
- Snapshot memory budget. Each unique
view-transition-nameallocates a separate bitmap in GPU memory. Mid-tier devices typically handle 10–15 concurrent names before VRAM pressure causes the browser to fall back to CPU rasterisation. Keep named elements to the critical shared elements only; usecompositor-only property optimizationprinciples to avoid promoting unnecessary layers. - Async callback depth. The
updateCallbackshould contain only the DOM swap (e.g.router.push()). Any additional async work — logging, analytics, unrelated data fetches — delays the “after” snapshot capture and inflates perceived transition latency. - SVG filters and backdrop-filter. Both can trigger expensive GPU compositing within a transition group. Benchmark with the Rendering panel’s Frame Rendering Stats overlay enabled; if GPU raster time spikes, remove or simplify filters on transitioning elements.
- Browser support. As of mid-2025 the same-document View Transitions API is available in Chrome 111+ and Safari 18+; Firefox support is behind a flag. The
canTransition()guard in Step 1 covers the unsupported case.
Frequently asked questions
How do I handle shared components with dynamic IDs across routes?
Derive a stable view-transition-name from a data attribute — for example, data-transition-key — before calling startViewTransition. Set the name via element.style.setProperty('view-transition-name', 'product-' + item.id) on both the outgoing and incoming element. Clear it in transition.finished if the element persists across multiple navigations, to avoid stale names accumulating.
Can I transition between different subdomains?
No. The API enforces same-origin policy. A navigation from app.example.com to store.example.com triggers a full page load and the browser discards both snapshots.
What is the performance impact of many concurrent view-transition-name assignments?
Each unique name creates a separate snapshot layer in GPU memory. Exceeding roughly 10–15 concurrent names on mid-tier devices can exhaust VRAM and trigger fallback rasterisation on the CPU, causing severe jank. Profile with the Layers panel to see active compositing contexts.
How do I gracefully degrade for browsers without View Transitions API support?
The canTransition() guard (Step 1) routes the code to a plain router.push() call. Pair that with the CSS .route-enter / .route-exit keyframe fallback from Step 4 so users on older browsers still get a lightweight fade.
Related
- View Transitions API Implementation — parent topic covering the full transition lifecycle, pseudo-element tree, and browser rendering model
- Modern View Transitions & Scroll APIs — pillar overview linking scroll-driven animations, container-query triggers, and
@starting-styleentry effects - Compositor-only Property Optimization — keep your transition animations strictly on the GPU by restricting to
transformandopacity - Avoiding Layout Thrashing in CSS Animations — understand why mutating layout-triggering properties inside a transition callback causes frame drops