View Transitions for multi-page apps

For years, "smooth animated page transitions" was a reason to reach for a single-page framework: take over routing, keep the document alive, animate the difference. That trade is no longer necessary. With cross-document view transitions a plain multi-page site — static HTML, server-rendered pages, an old PHP app — can animate between real navigations with roughly ten lines of CSS and no router at all. This is a practical guide to how it works, what breaks, and where support actually stands.

Same-document vs cross-document

The View Transitions API has two halves, and conflating them is the most common source of confusion:

  • Same-document transitions animate a DOM change inside one page. You call document.startViewTransition(() => updateTheDom()). This is what SPAs use.
  • Cross-document transitions animate a real navigation from one document to another. There is no function to call. The trigger is a same-origin navigation — usually a user clicking a link — and both documents opt in via CSS.

For a multi-page site you want the second one, and the API surface is refreshingly small.

The minimum viable setup

Put this in the stylesheet that both pages share:

@view-transition {
  navigation: auto;
}

That's it. Both the outgoing and the incoming document must contain that at-rule; if only one does, nothing animates. With navigation: auto you opt in to transitions for navigations whose type is traverse (back/forward), or push/ replace when the navigation was started by the user interacting with page content rather than with browser UI. Type a URL in the address bar and you get no transition — by design.

The default animation is a cross-fade of the whole page. It is subtle, and on a content site that is often all you want. What the browser does under the hood: it snapshots the old page, holds it while the new document loads and reaches its first render opportunity, then cross-fades old to new inside a generated pseudo-element tree.

both documents need @view-transition { navigation: auto } old document link click pageswap snapshot taken pagereveal new doc ready cross-fade new page shown > ~4s to first render in Chrome = transition skipped
No API call starts a cross-document transition — the navigation does.

Shared elements: view-transition-name

A cross-fade is fine; the effect people actually want is an element that persists — a thumbnail growing into a hero image, a card title becoming a page heading. You get that by giving the same element on both pages the same view-transition-name:

.post-hero img { view-transition-name: hero; }
.card--active img { view-transition-name: hero; }

When the browser finds a matching name in the old and new snapshot, it stops treating them as separate fades and instead morphs position and size between the two. Two rules matter:

  • A name must be unique per document at transition time. Give every card in a list view-transition-name: hero and the transition is skipped entirely.
  • The element must be rendered and not fragmented — no display: none, and inline elements broken across lines don't participate cleanly.

The uniqueness rule is why lists need a little JavaScript: on click, set the name on just the clicked card. Modern CSS softens this — view-transition-name: match-element auto-generates a per-element name — but for the classic list-to-detail pattern you still usually assign the name explicitly.

Customising the animation

The transition is exposed as a pseudo-element tree on the root, so it's animated with ordinary CSS keyframes. The pieces you'll touch are ::view-transition-old(name) and ::view-transition-new(name) for the two snapshots, and ::view-transition-group(name) for the container that morphs geometry. Use root as the name to target the whole-page default:

::view-transition-old(root) {
  animation: 220ms ease-out both fade-out;
}
::view-transition-new(root) {
  animation: 260ms ease-in both slide-up;
}

Two practical notes. First, keep durations short — 200–300ms. Page transitions sit directly in front of the user's intent, and anything longer reads as lag rather than polish. Second, wrap your custom animations in @media (prefers-reduced-motion: no-preference), or opt out with navigation: none for users who ask for reduced motion.

Different animations per route: transition types

You rarely want one animation everywhere: forward navigation should slide left, back should slide right, and going "up" to an index might zoom out. That's what types are for. Declare them in the at-rule:

@view-transition { navigation: auto; types: slide-forward; }

…then scope your keyframes with the matching pseudo-class, e.g. html:active-view-transition-type(slide-forward) ::view-transition-old(root) { … }. Because the at-rule lives in CSS, a static site can vary types per page template with nothing more than a different class or a per-template stylesheet. For dynamic decisions — "was this back or forward?" — set the types in JavaScript instead.

The two events worth knowing

  • pageswap fires on the outgoing page just before the snapshot is taken. This is your last chance to tag an element (set the shared view-transition-name on the card the user clicked) or to inspect the navigation via event.activation.
  • pagereveal fires on the incoming page before its first render. Use it to tag the destination element, add transition types, or call event.viewTransition.skipTransition() to bail out.

Both give you event.viewTransition, whose finished promise resolves when the animation is over — handy for cleaning up temporary names so they don't leak into the next navigation.

Support and failure modes

As of August 2026: Chrome and Edge support cross-document transitions from 126, Safari from 18.2 (desktop and iOS). Firefox ships same-document transitions but not the cross-document @view-transition at-rule, so it is not yet Baseline. That's fine — unsupported browsers just navigate instantly. There is no polyfill to maintain and no fallback path to write, which makes this an unusually cheap progressive enhancement.

When a transition silently doesn't happen, work down this list:

  1. Both documents opt in? Miss one and you get nothing.
  2. Same origin, no cross-origin redirect in the chain?
  3. Was the navigation started from page content, not browser UI?
  4. Any duplicated view-transition-name in either document?
  5. Is the destination slow to first render? Chrome aborts after roughly four seconds with a TimeoutError. A blocking font or a huge synchronous script is the usual culprit.

Should you use it?

For content sites, docs, portfolios and server-rendered apps: yes, and start with the plain cross-fade. It costs three lines, it can't break anything in Firefox, and it removes one of the last real arguments for turning a perfectly good multi-page site into a client-side router. Add shared-element morphs only where they carry meaning — list to detail, thumbnail to hero — because the effect gets noisy fast when everything moves.

The wider lesson is the same one behind the Temporal API and WebTransport: capabilities that used to justify a framework keep landing in the platform itself. Every time that happens, the sensible default architecture gets a little simpler.

Related
→ The JavaScript Temporal API: replacing Date → WebSocket vs SSE vs WebTransport → Portfolio & projects
Do cross-document view transitions need JavaScript?

No. On a multi-page site the navigation itself is the trigger, so the minimum setup is pure CSS: @view-transition { navigation: auto; } in the stylesheet shared by both pages. JavaScript is only for extras, like assigning a view-transition-name dynamically in pageswap or pagereveal.

Which browsers support cross-document view transitions?

Chrome and Edge from 126, Safari from 18.2 (including iOS). Firefox supports same-document transitions but not the cross-document @view-transition at-rule yet, so Firefox users get an ordinary instant page load — a safe progressive enhancement.

Why is my view transition not firing?

Usually one of: only one page opts in, the navigation isn't same-origin (or hits a cross-origin redirect), it was triggered by browser UI instead of page content, a view-transition-name is duplicated, or the new page took longer than about four seconds to render and Chrome skipped the transition.

Do view transitions hurt SEO or Core Web Vitals?

They don't change your HTML or URLs, so indexing is unaffected. The risk is perceived speed: the browser holds the old page while the new one prepares, so a slow destination delays first paint. Keep render-blocking work small.