Same-Document vs Cross-Document View Transitions
The View Transition API ships in two distinct activation modes: document.startViewTransition(), which animates between two states of the same document, and the @view-transition { navigation: auto } at-rule, which animates across a full cross-document navigation between two same-origin pages. Both produce the identical ::view-transition pseudo-element tree described in How @view-transition Works Under the Hood, and both obey the compositor rules covered throughout Core Animation Fundamentals & Browser Mechanics β but snapshot timing, lifecycle events, and what state survives the swap differ fundamentally. Picking the wrong mode means rebuilding your site as a single-page app for no reason, or fighting a full page load for state it destroys by design.
When to use this approach
The decision is architectural before it is technical: it hinges on whether a real navigation happens between the two states.
Prefer same-document (startViewTransition) when:
- Your site is already an SPA β a client-side router swaps DOM without changing documents, so there is no navigation for
@view-transitionto hook. This is the mode behind page-swap animations in single-page apps. - The transition is not a navigation at all: expanding a card, reordering a list, switching a tab. Cross-document mode cannot express these.
- You need imperative control β awaiting
transition.readyto add extra Web Animations, ortransition.finishedto sequence follow-up work, as in the scroll-timeline re-attachment pattern from the View Transition API vs FLIP comparison.
Prefer cross-document (@view-transition) when:
- Your site is a multi-page app (MPA): server-rendered pages, static site, classic templating. One declarative opt-in in both pagesβ CSS gives you animated navigation with zero router code.
- The state an SPA would preserve (fetched data, component trees) is cheap to re-render server-side, and the smooth swap was the only thing you actually wanted from an SPA conversion.
- You want each navigation to reset memory and JS state β a document teardown per route is a garbage collector you get for free.
Use both when an MPA has islands of in-page interactivity: cross-document transitions for links, startViewTransition for in-page state changes. The modes share all pseudo-element CSS, so the investment compounds.
Lifecycle comparison
The diagram below aligns the two lifecycles. The critical difference is where the old-state snapshot is captured: same-document captures it inside one document before your callback runs; cross-document captures it on the outgoing document as it unloads, then hands the bitmaps to a completely different document.
Implementation
Step 1 β Same-document: wrap the DOM update
Same-document mode is imperative. You hand the browser a callback; it captures the old state before invoking it, and treats the live DOM as the new state once the callbackβs promise settles:
// SPA route change, tab switch, list reorder β any same-document swap
const transition = document.startViewTransition(async () => {
// Old snapshots are already captured at this point.
// Rendering is paused while this callback runs β keep it fast.
await renderNewRoute();
});
// Three promises expose the lifecycle:
await transition.updateCallbackDone; // DOM mutation finished
await transition.ready; // pseudo-elements exist, animations starting
await transition.finished; // everything done, pseudo-tree removed
Because rendering is frozen between old-capture and new-capture, the user never sees the intermediate DOM. That freeze is also the modeβs main hazard: a slow callback holds the page visually frozen at the old snapshot.
Step 2 β Cross-document: opt in on both pages
Cross-document mode is declarative. Both the outgoing and incoming page must carry the at-rule; the navigation must be same-origin; and the transition triggers on the navigation itself β no JavaScript required:
/* Shared stylesheet, loaded by BOTH pages */
@view-transition {
navigation: auto;
}
/* Pseudo-element styling is identical to same-document mode */
::view-transition-old(root) {
animation: fade-out 0.25s ease-out forwards;
}
::view-transition-new(root) {
animation: fade-in 0.25s ease-in forwards;
}
@keyframes fade-out { to { opacity: 0; } }
@keyframes fade-in { from { opacity: 0; } }
If only one page opts in, no transition runs β the browser requires consent from both documents because the old pageβs pixels are shown on top of the new one.
Step 3 β Hook pageswap and pagereveal for per-navigation logic
Cross-document mode has no callback, so its two lifecycle events replace the promises. pageswap fires on the old document as the last chance to touch it before the snapshot set is finalized; pagereveal fires on the new document before its first render:
// On the OUTGOING page: customize what gets captured
window.addEventListener('pageswap', (event) => {
if (!event.viewTransition) return; // navigation isn't transitioning
const toUrl = new URL(event.activation.entry.url);
if (toUrl.pathname.startsWith('/articles/')) {
// Tag the clicked card so it morphs into the article header
activeCard.style.viewTransitionName = 'article-hero';
}
});
// On the INCOMING page: match the name before first render
window.addEventListener('pagereveal', (event) => {
if (!event.viewTransition) return; // e.g. bfcache restore, or no opt-in
document.querySelector('.article-header')
.style.viewTransitionName = 'article-hero';
// Optional: bail out entirely for some navigations
// event.viewTransition.skipTransition();
});
Assigning view-transition-name dynamically in these events β rather than statically in CSS β is the standard trick for morphing the specific element the user clicked out of a list, the cross-document equivalent of the shared-element patterns in cross-route element morphing.
Step 4 β Guard against partial support
Same-document and cross-document support levels differ per engine, so detect them independently. A router-level fallback mirrors the guard used in choosing CSS animations over JavaScript libraries: feature-detect, then degrade to an instant swap.
const sameDocOK = typeof document.startViewTransition === 'function';
const crossDocOK = typeof CSSViewTransitionRule !== 'undefined';
// Same-document guard
function swap(update) {
if (!sameDocOK) { update(); return; }
document.startViewTransition(update);
}
The @view-transition rule itself is harmlessly ignored by engines that do not parse it, so cross-document mode degrades to normal navigation with zero code. The full per-engine picture β including flags and quirks β lives in the View Transition browser support matrix.
What state carries across
This table is the real dividing line between the modes:
| State | Same-document | Cross-document |
|---|---|---|
| JS variables, component trees | Preserved β same document | Destroyed; rebuild or use sessionStorage |
| Event listeners, timers, observers | Preserved | Destroyed with the old document |
| Scroll position | Unchanged unless your callback changes it | New page starts fresh (browser may restore on back-nav) |
Snapshots (view-transition-name groups) |
Carried in-process | Carried across documents by the browser |
CSS transition styling (::view-transition-*) |
Applies | Applies identically β shared stylesheet works for both |
| In-flight fetches | Continue | Aborted on unload |
The only things that legitimately cross a cross-document boundary are the snapshot bitmaps, their geometry, and the name matching. Everything else must round-trip through the server, the URL, or storage β which an MPA is already architected to do.
When an MPA + cross-document transitions beats converting to an SPA
Teams historically converted to SPAs for two things: no white flash between pages, and continuity of on-screen elements. Cross-document view transitions deliver both without the conversion, which removes the SPAβs costs:
- No router, no hydration, no client-side data cache. The server keeps rendering complete pages; the browser animates between them. Shipped JS can approach zero.
- Per-navigation memory reset. SPA sessions leak β detached nodes, stale subscriptions, growing stores. A document teardown per navigation caps memory by construction.
- Progressive enhancement is intrinsic. Unsupported browsers get a normal, fully functional navigation. An SPAβs fallback story is far more involved.
- Streaming still works. The new document renders progressively; the transition overlays the old snapshot until first render. (Use
<link rel="expect">render-blocking if a named element must exist before the reveal.)
Convert to an SPA anyway when you genuinely need persistent cross-page state: an audio player that keeps playing, a WebSocket session, a canvas that cannot be serialized, or sub-second interaction loops where even a fast navigation round-trip is too slow. Otherwise the MPA route is less code and fewer failure modes β and frameworks that need same-document transitions, like the setups covered for React Router view transitions, can still adopt them incrementally inside islands.
Verification
- Same-document: DevTools β Animations drawer β trigger the swap. You should see one animation group per
view-transition-name, and the::view-transitiontree under<html>in the Elements panel for the duration. - Cross-document: enable Preserve log, navigate, and confirm
pageswaplogs on the old page andpagerevealon the new one. Ifevent.viewTransitionisnullon both, one page is missing the@view-transitionopt-in or the navigation is not same-origin. - Confirm the freeze budget: in a Performance recording, the gap between old-capture and first animated frame should stay under ~100 ms. Longer means a slow update callback (same-doc) or slow first render (cross-doc).
- Test back/forward navigation explicitly: bfcache restores fire
pagerevealwithevent.viewTransitionpopulated only when a transition is actually running β never assume it is non-null.
Edge cases and gotchas
The update callback freezes rendering (same-document). Everything inside startViewTransition(cb) runs while the page is visually frozen on the old snapshot. Fetch data before calling it; the callback should only commit DOM.
pageswap is your last microtask (cross-document). The old document is being torn down; you cannot await anything asynchronous in the handler and expect it to affect the capture. Set view-transition-name synchronously.
Names must match one-to-one at both ends. A view-transition-name present on the old page but absent on the new one produces an exit-only animation (and vice versa an entry-only one) β often intended, sometimes a silent bug. Duplicate names on one page skip that transition, same as in same-document mode.
Redirects and cross-origin hops break the chain. A same-origin navigation that bounces through a cross-origin redirect loses the transition. Auth flows are the classic trigger.
Reduced motion applies to both modes. Gate startViewTransition behind a prefers-reduced-motion check in JS; for cross-document, neutralize ::view-transition-* animations in CSS or call skipTransition() inside pagereveal.
Browser-specific notes
Chrome / Edge. Same-document transitions since Chrome 111; cross-document @view-transition, plus the pageswap/pagereveal events, since Chrome 126. The most complete implementation and the reference for both modes.
Safari. Same-document support shipped in Safari 18; cross-document @view-transition arrived in Safari 18.2. Test snapshot geometry on iOS specifically β fixed-position elements inside named groups have historically needed workarounds.
Firefox. Same-document support is in active development (arriving around Firefox 144); treat it as not yet dependable and keep the feature-detection guard. Cross-document support has not shipped β MPAs simply get instant navigations, which is the correct fallback.
Because the modes detect differently, never infer one from the other: Chrome 111β125 is the concrete case of same-document support without cross-document support.
Related
- View Transition Browser Support Matrix β per-engine versions, flags, and safe deployment recipes for both modes
- Detecting View Transition Support with @supports and JavaScript β the feature-detection patterns from Step 4, in depth
- Building Reduced-Motion Variants of View Transitions β accessible alternatives for both activation modes
- Fallback Strategies for Legacy Browsers β what unsupported engines should experience instead
- Browser Support & Progressive Enhancement β the guard discipline that keeps both modes optional