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-transition to 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.ready to add extra Web Animations, or transition.finished to 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.

Same-document vs cross-document view transition lifecycle Two horizontal timelines. The same-document timeline stays within one document: startViewTransition is called, old snapshots are captured, the update callback mutates the DOM, the new state is captured, and pseudo-elements animate, with the updateCallbackDone, ready, and finished promises marked. The cross-document timeline spans two documents: a link is clicked, pageswap fires and old snapshots are captured on the outgoing page, the navigation loads the new document, pagereveal fires and the new state is captured, then pseudo-elements animate on the new page. JavaScript state is destroyed at the document boundary; only snapshots and names carry over. Same-document β€” one document throughout Cross-document β€” snapshots handed across a navigation startViewTransition (JS call) capture old snapshots update callback mutates DOM capture new state (live) pseudo-elements animate updateCallbackDone ↑ ready ↑ finished ↑ old document (unloading) new document (activating) link click / navigation pageswap fires, capture old snapshots navigate pagereveal fires, capture new state pseudo-elements animate JS state, timers, DOM: destroyed only bitmaps + names carry over

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

  1. Same-document: DevTools β†’ Animations drawer β†’ trigger the swap. You should see one animation group per view-transition-name, and the ::view-transition tree under <html> in the Elements panel for the duration.
  2. Cross-document: enable Preserve log, navigate, and confirm pageswap logs on the old page and pagereveal on the new one. If event.viewTransition is null on both, one page is missing the @view-transition opt-in or the navigation is not same-origin.
  3. 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).
  4. Test back/forward navigation explicitly: bfcache restores fire pagereveal with event.viewTransition populated 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.


Up: How @view-transition Works Under the Hood