Cross-Document View Transitions Without a Router

Cross-document view transitions animate the change between two separate HTML documents, so a multi-page site can morph one page into the next without a client-side router. You opt in with a single CSS at-rule on both pages. No JavaScript, no framework, no hydration. Browsers without support simply load the next page the old way.

That last part is why this is worth doing now rather than in two years.

How do you turn on cross-document view transitions?

Put this in a stylesheet that both the outgoing and the incoming page load:

@view-transition {
  navigation: auto;
}

Both documents need it. The transition is a negotiation between two pages, and if the destination has not opted in, nothing happens.

The navigation descriptor takes auto or none. auto covers the traverse, push and replace navigation types, which in practice means links, form submissions and the back and forward buttons. It deliberately excludes typing a URL into the address bar and hitting reload. The navigation also has to be same-origin with no cross-origin redirects along the way.

With nothing else configured you get a crossfade of the whole page. Two lines of CSS for a real change in how a site feels, and for a lot of sites that is where the work should stop.

Why won’t my elements morph between pages?

Because the browser has no idea that the thumbnail on the index page and the hero image on the article page are the same thing. You tell it with view-transition-name, and the name has to be identical in both documents.

/* index page */
.card-thumb[data-post="42"] {
  view-transition-name: post-42-image;
}

/* article page */
.hero-image {
  view-transition-name: post-42-image;
}

Two rules bite here.

Names must be unique among rendered elements. If two visible elements carry the same view-transition-name at the same moment, ViewTransition.ready rejects and the entire transition is skipped, per MDN’s documentation of the property. On a listing page that means generating a distinct name per item. One name on .card-thumb will silently kill the animation for every card on the page.

The second rule catches people out more. You cannot solve that uniqueness problem with match-element, which exists for exactly the “give every list item its own name” case. The identifiers it generates are internal and tied to element identity, so they cannot be matched across documents, and MDN states plainly that it works for same-document transitions only. Safari’s original announcement used view-transition-name: auto for this; auto was later renamed to match-element in the specification, and the same restriction applies to both. For cross-document work you write the names yourself, usually derived from an id or slug the template already has.

How do you animate back navigation in reverse?

Transition types. You can declare them in the at-rule, or add them at runtime from the incoming page and key your animations off the :active-view-transition-type() pseudo-class.

window.addEventListener('pagereveal', (e) => {
  if (!e.viewTransition) return;

  const from = new URL(navigation.activation.from.url);
  const to = new URL(navigation.activation.entry.url);
  const goingBack = to.pathname.length < from.pathname.length;

  e.viewTransition.types.add(goingBack ? 'backwards' : 'forwards');
});

pagereveal fires on the incoming document after it initialises but before its first render, which is the only window in which you can still influence the snapshot. Chrome’s documentation is specific about the consequence: that listener has to sit in a parser-blocking script in the <head>. A deferred module runs too late and your types never land.

Its counterpart, pageswap, fires on the outgoing document just before its final frame and hands you e.activation.entry.url if you need to know where the user is heading before the snapshot is taken.

Then the animations hang off the type:

@keyframes slide-from-right { from { translate: 100vw 0; } }
@keyframes slide-to-left { to { translate: -100vw 0; } }

html:active-view-transition-type(forwards)::view-transition-old(root) {
  animation: slide-to-left 300ms ease;
}
html:active-view-transition-type(forwards)::view-transition-new(root) {
  animation: slide-from-right 300ms ease;
}

Mirror those keyframes for backwards and the site starts to read like a stack rather than a slideshow.

Which browsers support it as of August 2026?

Chrome and Edge from 126. Safari from 18.2, whose release notes announce support for “cross-document View Transitions with @view-transition”.

Firefox is the gap. Version 144 added the View Transition API, but the release notes scope it to single-page applications and say nothing about @view-transition or cross-document navigation. MDN still marks the at-rule as limited availability, which is to say not Baseline.

Less of a problem than it sounds. There is no polyfill to ship, no feature detection to write and no fallback path to maintain, because the fallback is an ordinary page load. Firefox users get the site you already had.

Two things will still cancel a transition on a slow connection, though. Chrome’s documentation puts a cap of roughly four seconds on how long the browser waits for the new document before abandoning the transition and doing a plain load. And if the incoming page paints before its main content has parsed, the snapshot captures a half-empty page. A render-blocking hint fixes the second one:

<link rel="expect" blocking="render" href="#main-content">

If you are regularly hitting the four second limit, the answer is faster page loads rather than a longer animation.

Do you still need Astro’s ClientRouter?

Astro’s documentation is unusually honest about this:

As browser APIs and web standards evolve, using Astro’s <ClientRouter /> for this additional functionality will increasingly become unnecessary. We recommend keeping up with the current state of browser APIs so you can decide whether you still need Astro’s client-side routing for the specific features you use.

The division is clean. <ClientRouter /> turns a multi-page app into a single-page app, which buys you persistent elements via transition:persist, state that survives a page change, and a set of lifecycle events to hook into. Native transitions give you the animation and nothing else, for zero JavaScript.

If animation was the only reason you added the router, remove it. An audio player that keeps playing across pages, or a sidebar whose scroll position has to survive, is still a good reason to keep it.

How do you handle prefers-reduced-motion?

Wrap anything beyond the default crossfade in a prefers-reduced-motion: no-preference query. A full-page slide is the sort of movement that makes some people genuinely unwell, and it is one of the first things an accessibility audit against WCAG picks up.

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

What I would actually ship: the at-rule, plus named transitions on two or three elements that genuinely persist between pages, such as the logo, the hero image and the page title. The crossfade and one morphing element gets you most of the perceived benefit. A dozen named elements is where it starts to look like a screensaver and where the uniqueness failures begin.

Skip it altogether if your pages take more than about a second to respond. The transition holds the old page on screen while it waits for the new one, so on a slow site you replace a visible page load with a frozen screen where nothing moves at all. Fix the response time first, then add the animation.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch