View transition not working on page navigation: both documents must opt in

Web Platform · Intermediate · 6 min read · published

This article was written by Claude (Anthropic) and published automatically.

What this solves: You added @view-transition { navigation: auto } and navigating between pages still snaps with no animation. Here's the checklist of what silently disables it.

What Changed

If your view transition is not working on page navigation between two ordinary URLs, the likely cause is that only one of the two documents opted in. Cross-document view transitions let a full page navigation (a real <a href> that unloads the document) animate between the old and new page with no client-side router at all. You opt in with a CSS at-rule:

@view-transition { navigation: auto; }

This shipped in Chromium 126 and later arrived in Safari's 18.x line; Firefox has been implementing it behind a flag. Before this, the View Transitions API only worked within one document via document.startViewTransition(), which meant SPA-only.

The at-rule is silent when it doesn't apply. There is no error, no warning for the common failure — the page just swaps. That is what makes it frustrating to debug.

The Old Way vs The New Way

Before — you needed a router that intercepted the click, fetched the next page, and swapped DOM yourself:

// MPA "fake SPA" just to get an animation
document.addEventListener('click', async (e) => {
  const a = e.target.closest('a[href^="/"]');
  if (!a) return;
  e.preventDefault();
  const html = await fetch(a.href).then(r => r.text());
  const doc = new DOMParser().parseFromString(html, 'text/html');
  document.startViewTransition(() => {
    document.body.replaceWith(doc.body);
    history.pushState({}, '', a.href);
  });
});
// now you own: scroll restore, focus, <head> diffing, script re-exec, back/forward

After — no JavaScript, and it works on back/forward too:

/* MUST be in BOTH the outgoing and the incoming document */
@view-transition { navigation: auto; }

.hero-img { view-transition-name: hero; } /* unique per document */

::view-transition-old(root) { animation-duration: 180ms; }
::view-transition-new(root) { animation-duration: 180ms; }

Why It Was Added

The entire "we need an SPA for the animations" argument was a performance tax. Teams shipped a router, a fetch layer, and hand-rolled scroll/focus restoration purely for a 200ms crossfade, then spent months fixing the accessibility and back-button regressions that came with it.

Cross-document transitions remove a class of bugs rather than adding a feature: no stale <head>, no double-executed analytics scripts, no broken focus after navigation, no manual history management. The browser still does a normal navigation — it just doesn't paint the new document until it has a snapshot to animate from.

The common adoption failures, in the order you should check them:

  1. Only one page opted in. Both the outgoing and incoming documents need @view-transition. Sharing one global stylesheet is the easy fix; a per-route CSS file is the usual culprit.
  2. Cross-origin navigation. Same-origin only, always.
  3. Reloads. A refresh never transitions. Back/forward traversals do.
  4. Duplicate view-transition-name. Two elements with the same name abort the whole transition. Console: Unexpected duplicate view-transition-name: card.
  5. prefers-reduced-motion: reduce — the default UA animation is suppressed, which looks like "nothing happened."
  6. Non-GET navigations (a form POST) were not covered by early implementations.

How It Works Underneath

The navigation is real. The browser snapshots the old document just before it goes away, then render-blocks the new document briefly so it can capture a matching "new" snapshot and animate between the two pseudo-element trees.

Two events let you participate: pageswap fires on the outgoing window (you can still read the old DOM and assign names), and pagereveal fires on the incoming window before first paint.

sequenceDiagram
  participant Old as Outgoing document
  participant B as Browser
  participant New as Incoming document
  Old->>B: click <a href> (same-origin GET)
  B->>Old: check @view-transition in old doc
  Old->>Old: pageswap event (viewTransition available)
  B->>Old: capture old snapshots by view-transition-name
  B->>New: fetch + parse
  B->>New: check @view-transition in new doc
  alt new doc did NOT opt in
    B->>New: skip transition, paint immediately
  else both opted in
    New->>New: pagereveal event, first paint render-blocked
    B->>B: capture new snapshots, build ::view-transition tree
    B->>New: animate old -> new, then release to normal paint
  end

Because the incoming paint is briefly blocked, a slow-rendering new page makes the transition feel laggy rather than smooth — the browser gives up after a short timeout and just paints. Use pagereveal to hold the transition only as long as you truly need:

window.addEventListener('pagereveal', async (e) => {
  if (!e.viewTransition) return;            // transition didn't happen
  const id = sessionStorage.getItem('clickedCard');
  if (id) document.getElementById(id).style.viewTransitionName = 'card';
  await e.viewTransition.finished;
  document.querySelectorAll('[style*=view-transition-name]')
    .forEach(el => el.style.viewTransitionName = '');
});

Should You Adopt It Yet

Yes, for progressive enhancement — and only for that. It degrades to an instant navigation in unsupported browsers, which is exactly what you have today, so the downside is zero. Adding four lines of CSS to a server-rendered site is one of the highest-value-per-line changes available.

Wait if: your animation is load-bearing to the UX (choreographed onboarding), you depend on it in Firefox, or your navigations are form POSTs. Also be careful on long pages — the default root crossfade over a 4000px document is a large snapshot, and on low-end Android it can cost more than the animation is worth. Measure INP before and after.

Do not rebuild an SPA into an MPA just for this. The win is for sites that were already MPAs.

Migration Notes

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation: none !important; }
}

Key takeaway: Cross-document view transitions only run when both the outgoing and incoming document opt in with @view-transition, the navigation is same-origin, and every view-transition-name on the page is unique.

Real-world challenge

Your product listing page animates beautifully into a product detail page in local testing with two demo products. On the real catalogue page with 40 products, clicking any card produces no transition at all — the page just swaps. DevTools console shows: 'Unexpected duplicate view-transition-name: card'. The CSS is `.product-card { view-transition-name: card; }`.

Diagnosis. view-transition-name must be unique within a document. With two demo products the bug may not have surfaced; with 40 cards sharing view-transition-name: card, the browser aborts the whole transition and logs the duplicate-name warning. Nothing animates — not even elements with valid unique names.

Fix option 1 — unique names per card. Cheap, but 40 named elements means 40 snapshots on every navigation.

.product-card { view-transition-name: attr(data-id type(<custom-ident>)); }

If attr() with type isn't available in your target browsers, emit the name server-side: style="view-transition-name: card-{{id}}".

Fix option 2 (preferred) — name only the clicked card. Assign the shared name at click/pageswap time so exactly one element carries it:

document.addEventListener('click', (e) => {
  const card = e.target.closest('.product-card');
  if (!card) return;
  document.querySelectorAll('.product-card')
    .forEach(el => el.style.viewTransitionName = '');
  card.style.viewTransitionName = 'card';
});

The detail page names its hero image card too, and the morph works with a single snapshot pair. Then verify in the console that the duplicate warning is gone — a single leftover duplicate kills the entire transition, not just that element.