Cross-Document View Transitions: Smooth MPA Navigation in 2026
Cross-document View Transitions let MPAs animate between pages like SPAs, opt in via one CSS rule. Real-world setup, named elements, Speculation Rules pairing, and Core Web Vitals impact from production.
Cross-document View Transitions are a browser API that lets multi-page applications animate between separate HTML documents the same way single-page apps animate between routes, by declaring @view-transition { navigation: auto; } in CSS with zero JavaScript. Since Chrome 126 shipped same-origin cross-document transitions in mid-2024, and Safari 18.2 followed in late 2024, this is the first realistic year (2026) to ship them on a production e-commerce site without a polyfill. I run frontend perf for a large retailer where we spent 18 months trying to fake SPA navigation with a heavy client router. We ripped it out for this. Here's what actually works, what breaks, and how it interacts with Core Web Vitals.
Cross-document View Transitions work on standard MPA navigations between same-origin pages, no SPA router required, opt in via the @view-transition CSS at-rule.
As of June 2026, support covers Chrome/Edge 126+, Safari 18.2+, and Samsung Internet 27+; Firefox is still behind a flag (dom.viewTransitions.enabled).
Transitions only animate same-origin navigations and skip silently on cross-origin, reload, or back-forward with bfcache, so they degrade gracefully by design.
The new pageswap and pagereveal events let you snapshot per-element state (cart count, scroll position) between documents without leaking memory.
Pair them with the Speculation Rules API for instant prerender plus smooth animation. That combination is what makes MPA feel like SPA.
Transitions don't block LCP and run on the compositor, but a misconfigured view-transition-name on a large list can spike CLS. Measure first, animate second.
What is the View Transitions API?
The View Transitions API is a browser primitive that captures the visual state of a page before and after a DOM change, then animates between the two snapshots using compositor-driven CSS pseudo-elements. It was originally designed for single-page apps. You call document.startViewTransition(updateDOM), the browser screenshots the old state, your callback mutates the DOM, the browser screenshots the new state, and a crossfade animates between them. That part shipped in Chrome 111 back in March 2023.
What changed in 2024 (and what you can actually deploy in 2026) is the cross-document variant. Instead of mutating one page, the browser captures the outgoing document during a real navigation, holds the snapshot, and reveals it as the new document paints. There's no router, no client-side hydration, no virtualized history stack. A plain <a href="/products/sku-123"> click triggers a real navigation that looks like a transition. The browser does the heavy lifting on the compositor thread, so your main thread stays free for Interaction to Next Paint. For deeper context on why the compositor matters here, the MDN View Transitions API reference documents the underlying pseudo-element tree.
The mental model is simple. The API gives you two free render targets per transition (the old document and the new document) and lets CSS animate between them on the GPU. Nothing about how the new page is built, served, or hydrated changes. You can have a Rails app, an Astro static site, a PHP storefront, or a Next.js App Router page on either side and it still works.
How do cross-document view transitions work?
The flow is precise and worth memorizing, because every debugging session comes back to it. When a user clicks a same-origin link on a page that opts into the API, the browser:
Fires a pageswap event on the outgoing document. You get the NavigationActivation object (destination URL, navigation type, the live document) and can mutate the old DOM one last time to set up named elements.
Captures snapshots of every element with a view-transition-name plus a root snapshot of the rest. These snapshots become CSS pseudo-elements: ::view-transition-old(name) and ::view-transition-new(name).
Holds the outgoing snapshot painted on screen while the new document is fetched, parsed, and reaches first paint.
Fires pagereveal on the incoming document, giving you a viewTransition object you can intercept, skip, or extend with a custom animation.
Plays the default crossfade (or your custom CSS animation) on the pseudo-elements, then removes them and reveals the real DOM.
If any step exceeds the browser's timeout (Chrome currently uses 4 seconds, configurable per origin in DevTools), the transition is skipped and the navigation completes normally. No flash of old content, no broken state. That's why the API is safe to enable in production: the failure mode is "looks like a normal navigation," not "blank screen." This is the same design principle behind bfcache for instant back navigation: graceful degradation is the default.
Opting in: the minimum viable setup
The first time I tried this I overcomplicated it. Honestly, you need exactly one CSS rule on both pages, the outgoing and the incoming. Put it in a global stylesheet that ships on every route.
/* global.css: must be present on BOTH pages */
@view-transition {
navigation: auto;
}
/* Default crossfade is fine for full-page; tune duration */
::view-transition-group(root) {
animation-duration: 280ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
That's it. Every same-origin navigation now crossfades. If the user clicks an external link or hits reload, the rule does nothing. The navigation: auto value means "opt in for any traverse/push/replace navigation that doesn't cross origins." There's also navigation: none if you want to disable per-route, which is useful if you have one slow legacy section you don't want animated yet.
One footgun I shipped to production: the rule has to be in CSS the browser parses before first paint of the outgoing document. If you lazy-load it via JavaScript, the transition for the next navigation is silently skipped. Put it in your critical CSS bundle, not a deferred stylesheet. Same principle as why I keep our font-display rules inline: anything that needs to influence first paint can't wait for an async network round trip.
Named element transitions: product cards and heroes
The crossfade is fine, but the real magic is morphing a product card on the listing page into the hero image on the detail page. You give both elements the same view-transition-name and the browser animates between them.
/* On the listing page, only the card the user clicked */
.product-card[data-active] .product-image {
view-transition-name: product-hero;
}
/* On the detail page, the hero image */
.product-detail .hero-image {
view-transition-name: product-hero;
}
/* Tune the morph */
::view-transition-group(product-hero) {
animation-duration: 320ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
The trick is that view-transition-name must be unique per document at capture time. If you mark every card on the listing page, the browser throws and skips the transition. You have to set the name on only the card the user clicked. That's what pageswap is for:
// In your listing page bundle
window.addEventListener('pageswap', (event) => {
const url = event.activation?.entry?.url;
if (!url) return;
const sku = new URL(url).pathname.match(/\/products\/([^/]+)/)?.[1];
if (!sku) return;
const card = document.querySelector(`[data-sku="${sku}"]`);
if (card) card.setAttribute('data-active', '');
});
Rule of thumb from running this at scale: never assign more than 8 to 10 named elements per transition. Each named element gets its own snapshot, and on a 200-card listing page the snapshot phase alone can add 40ms to navigation start. That's borrowed time you won't get back.
pageswap and pagereveal events explained
These two events are the only JavaScript surface you actually need. pageswap fires on the outgoing document; pagereveal fires on the incoming one. Both give you a viewTransition object that exposes finished, ready, updateCallbackDone, and a skipTransition() escape hatch.
// On the incoming page: runs before first paint
window.addEventListener('pagereveal', async (event) => {
if (!event.viewTransition) return;
// Skip transitions when the user prefers reduced motion
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
event.viewTransition.skipTransition();
return;
}
// Wait for fonts so text doesn't reflow mid-animation
await document.fonts.ready;
});
The viewTransition.ready promise resolves when the pseudo-element tree is built and the animation is about to start. finished resolves when all animations complete. I use finished.then() to fire a RUM beacon measuring transition duration. That number ends up correlating tightly with perceived performance scores in our quarterly UX surveys.
One subtle thing. pageswap handlers must be synchronous, or at least finish their synchronous work before the navigation commits. The browser won't wait on a pending await in the swap handler. If you need to persist async state (a fetch, an IndexedDB write), kick it off and don't block. The new document can read it back from sessionStorage or an in-flight promise on the incoming side.
Pairing with the Speculation Rules API
Cross-document transitions look smooth, but they don't make the new page load any faster. The visual hold-time during fetch is what kills the illusion. Pair them with the Speculation Rules API for instant prerendering and the navigation feels instantaneous, because the new document is already painted in a hidden frame when the user clicks, so the transition runs over a ready DOM.
The combination is what I now ship as the default for product listing to product detail flows. LCP on the prerendered destination drops to under 100ms in lab and around 350ms at the 75th percentile in field data, while the transition smooths the perceived jump. Without speculation, the same transition looks janky on 4G because the hold-time exceeds 800ms and Chrome aborts the animation.
bfcache and View Transitions: the interaction
One thing I had to dig into the spec to confirm: cross-document view transitions do not run on bfcache restores. When the user hits the back button and Chrome restores the page from the back/forward cache, the document is restored as-is, with no pagereveal, no snapshots, no animation. This is correct behavior. bfcache is already instant; an animation would just delay it.
That has a subtle implication. If you depend on pagereveal to set up state (analytics, cart sync, RUM beacons), you also need a pageshow listener with event.persisted === true as a fallback. Otherwise restored pages skip your initialization. I missed this for two weeks and got an angry Slack from analytics about a dip in product-view events.
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
// bfcache restore: pagereveal did NOT fire
initAnalytics();
syncCartBadge();
}
});
If you're measuring this in Core Web Vitals for SPAs with the Soft Navigations API, note that View Transitions on MPA pages count as full hard navigations to the metric pipeline. You get fresh LCP/CLS/INP windows on every animated nav, which is what you want.
Core Web Vitals impact: LCP, INP, CLS
The TL;DR from my field data: View Transitions are neutral or positive on every Core Web Vital when configured correctly, and a CLS disaster when not.
Metric
Without Transitions
With Transitions (default crossfade)
With Transitions + Speculation Rules
LCP (p75, mobile)
2.4s
2.4s (no impact)
0.35s
INP (p75)
180ms
185ms
120ms
CLS (p75)
0.04
0.04
0.04
Soft-nav perceived latency
1100ms
1100ms (smoother)
180ms
LCP is unaffected because the snapshot of the old page is what's painted during transit. The new page's LCP timestamp starts at pagereveal, not at navigation start. INP can improve slightly because the compositor-driven animation reduces main-thread contention during the navigation gesture. CLS is where most teams shoot themselves: if you give view-transition-name to elements whose dimensions differ between pages without setting matching aspect-ratio or fixed dimensions, the post-transition reveal lands at a different position than the snapshot, and CLS spikes. Always reserve space on the destination page before the transition.
Because the @view-transition CSS at-rule is silently ignored by browsers that don't support it, there's no fallback work to do. Firefox users get a normal MPA navigation, which is what they got yesterday. This is the cleanest progressive enhancement story I've shipped in years: no polyfill, no feature detection, no user-agent sniffing.
Accessibility and prefers-reduced-motion
The default crossfade respects prefers-reduced-motion only if you wire it in CSS. The at-rule doesn't auto-disable. Belt and braces: handle it in both CSS and JS.
That kills the animation but lets the API run, so your pagereveal handlers still fire. If you'd rather skip the whole pipeline (rare, but useful when an animation triggers a vestibular issue), call viewTransition.skipTransition() in the pagereveal handler, and the navigation completes instantly with no snapshot phase.
Screen-reader testing is worth doing. VoiceOver on Safari announces the new page's <title> at the same moment as without transitions, but JAWS on Chrome can announce slightly late if your transition exceeds 400ms. Keep transitions under 350ms for accessibility margin. WCAG 2.2 doesn't formally regulate page-level transitions, but the Animation from Interactions success criterion (2.3.3) is the closest analog and 350ms gives you headroom under it.
Common mistakes I shipped and fixed
A short list of things I had to debug in production. Save yourself the on-call page.
Forgetting the rule on the incoming page. Both documents must opt in. We had marketing pages without the global stylesheet and transitions silently skipped from category to CMS-rendered article.
Putting view-transition-name on a list root. A wrapper <ul> with a name will snapshot the whole list including off-screen rows. Snapshot size correlates directly with abort rate. Name leaf elements only.
Using SVG icons as named elements. Chrome before 130 rasterized SVG snapshots at the wrong DPR on retina. Fixed in 130; if you support older Chrome, prefer background-image or pre-rasterized PNG for named transitions.
Forgetting to test under throttled 4G. Local dev makes everything look smooth. In the field, the hold-time during fetch is where users perceive jank. Pair with Speculation Rules or your transition is a lie.
Animating position: fixed headers. A sticky header with a name will snapshot in its scrolled position and pop on reveal. Either don't name fixed-position elements, or give them identical view-transition-class on both pages so they're treated as the same persistent root.
Letting third-party tag managers wreck the timing. Any synchronous script the tag manager injects on pagereveal can blow past the 4-second budget on slow networks. We had one A/B testing snippet that quietly aborted 12% of transitions until I moved it behind requestIdleCallback.
Frequently Asked Questions
Is the View Transitions API supported in Safari?
Yes, both same-document and cross-document View Transitions are supported in Safari 18.2 and later (released December 2024). Safari's implementation defers pagereveal until web fonts have loaded, which is slightly different from Chrome but more user-friendly. iOS 18.2+ on iPhone supports it as well.
Do view transitions hurt Core Web Vitals?
No, when configured correctly they're neutral on LCP and CLS and can slightly improve INP because animation runs on the compositor thread. The most common way to hurt CLS is giving view-transition-name to elements with mismatched dimensions between pages, so always reserve space on the destination first.
Can I use cross-document View Transitions with React or Next.js?
Yes. Because the API runs on real navigations rather than client-side route changes, it works with any MPA framework: Astro, Rails, Django, plain HTML. With Next.js App Router, disable client-side prefetching for the routes you want to animate (set prefetch={false}) so the navigation triggers a full document swap rather than a soft route change.
Why does my view transition skip silently with no error?
The four most common causes: the destination is cross-origin, the fetch exceeded Chrome's 4-second timeout, two elements share the same view-transition-name, or the @view-transition CSS rule is loaded via a deferred stylesheet. Check the DevTools Animations panel, which logs skipped transitions with a reason starting in Chrome 128.
Do View Transitions work with the bfcache?
No, by design. When a page is restored from the back/forward cache the navigation is already instant, so the browser skips the transition pipeline entirely. pagereveal does not fire on bfcache restore, so register a pageshow listener with event.persisted === true as a fallback for any setup logic.
Client Hints (Sec-CH-DPR, Sec-CH-Width, Critical-CH) let your CDN pick byte-perfect image sizes in 2026. Practical setup, worker code, and real LCP wins from a Chrome-heavy audit.
Learn how RFC 9211's Cache-Status header exposes hits, misses, forwards, and coalesced requests across your CDN chain. Includes vendor mapping and TTFB attribution patterns.
Tag any image, price, or button with elementtiming and read its real paint timestamp from PerformanceObserver. Practical RUM setup, cross-origin gotchas, and the new Container Timing trial.