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.
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: heroand 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
pageswapfires on the outgoing page just before the snapshot is taken. This is your last chance to tag an element (set the sharedview-transition-nameon the card the user clicked) or to inspect the navigation viaevent.activation.pagerevealfires on the incoming page before its first render. Use it to tag the destination element, add transition types, or callevent.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:
- Both documents opt in? Miss one and you get nothing.
- Same origin, no cross-origin redirect in the chain?
- Was the navigation started from page content, not browser UI?
- Any duplicated
view-transition-namein either document? - 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.