Tech · · 7 min read
Animating between two separate pages, with two lines of CSS
A multi-page site can cross-fade between navigations with no JavaScript and no router. The five conditions, the descriptor both pages need, and what still needs script.
A cross-document view transition animates between two ordinary page loads. No router, no single-page app, no JavaScript:
/* In both documents */
@view-transition {
navigation: auto;
}That is the whole opt-in. Click a link, and instead of the white flash of a navigation you get the same cross-fade a single-page app would have paid a framework for.
The catch is that a transition only happens when five conditions hold at once, and if any one of them fails the navigation is simply normal — no error, no warning, nothing in the console. Knowing the five is most of what it takes to use this feature without frustration.
drafts.csswg.org ↗CSS View Transitions Module Level 2The normative definition of cross-document transitions, the opt-in and the lifecycle. Source: CSS Working GroupThe five conditions
From the spec's own list, a navigation between two documents triggers a view transition when:
- Both documents are same-origin.
- The page is visible throughout the navigation.
- The user initiated it by interacting with the page — clicking a link, submitting a form — or by using browser UI to go back or forward.
- The navigation included no cross-origin redirects.
- Both documents opted in with
@view-transition.
Condition 3 rules out more than people expect. The navigation: auto descriptor applies to a traverse navigation, or to a push or replace navigation whose user involvement is not "browser UI". The spec spells out what that excludes: "navigating via the URL address bar or clicking a bookmark, as well as any form of user or script initiated reload".
So the sequence that catches most people first — edit the CSS, hit reload, see no transition — is working exactly as specified. Test with a link, not a refresh.
Condition 5 catches the rest. navigation has an initial value of none, and the spec says the descriptor "must be present on both the old and new document". If your blog post template has the rule and your index page does not, the transition from index to post will not fire.
Where to put the rule
The descriptor takes auto or none, and @view-transition is a plain at-rule with forward-compatible parsing, which means two useful things. A browser that does not understand it ignores it without error, so there is no @supports guard to write. And because it can be nested inside a conditional group rule, you can make the whole feature conditional:
@media (prefers-reduced-motion: no-preference) {
@view-transition {
navigation: auto;
}
}That is the version to ship. A view transition is motion across the entire viewport, and a full-page cross-fade on every navigation is exactly the kind of thing the setting exists for — see designing motion for people who asked for less of it for how to decide what stays.
The site-wide stylesheet is the right home for the rule, since every page that participates needs it.
Naming elements so they morph instead of fading
The default is a cross-fade of the whole page. To make an individual element travel between the two documents, give it the same view-transition-name in both:
/* In the list page */
.card__thumbnail { view-transition-name: hero-image; }
/* In the article page */
.article__hero { view-transition-name: hero-image; }Names have to be unique within a single transition. The spec is blunt about the consequence: if two elements simultaneously specify the same view transition name, "the view transition will abort". Reusing a name across the two pages is fine — that is the whole point — as long as two elements never carry it at the same moment.
That constraint is painful for lists, where hand-numbering every item is the obvious and wrong answer. Two keywords fix it:
view-transition-name: match-elementgenerates a name per element, so one rule covers a whole list. Chrome 137, Firefox 144, Safari 18.4.view-transition-classgives many differently-named elements a shared set of styles. Chrome 125, Firefox 144, Safari 18.2.
Transition types, and the descriptor you have to write twice
Types let one pair of pages animate differently depending on where the reader came from. The descriptor looks like this:
@view-transition {
navigation: auto;
types: from-list;
}The important sentence in the spec is easy to miss: "the types descriptor only applies to the Document in which it is defined. The author is responsible for using their chosen set of types in both documents."
There is no negotiation between the two pages. If you want a from-list transition, both the list page and the destination page have to declare or set that type, and the destination is usually the one that has to decide dynamically — which means script.
A <view-transition-type> is any custom identifier other than none that does not begin with -ua-.
What still needs JavaScript
Two events do the work, one in each document.
pageswap, in the old document, fires at the last moment before it is unloaded. Its viewTransition property is non-null when the navigation is eligible, and its activation property describes the navigation, including the destination URL after redirects.
addEventListener("pageswap", event => {
if (!event.viewTransition) return;
// Skip the transition on back/forward
if (event.activation.navigationType === "traverse") {
event.viewTransition.skipTransition();
return;
}
const to = new URL(event.activation.entry.url);
if (to.pathname.startsWith("/work/")) {
event.viewTransition.types.add("to-detail");
}
});pagereveal, in the new document, fires just before its first frame is presented. By then the captured elements from the old document are already in place and updateCallbackDone has resolved, so this is where you choose a type based on where the reader came from, or await ready and animate the pseudo-elements directly.
addEventListener("pagereveal", event => {
if (!event.viewTransition) return;
const from = new URL(navigation.activation.from.url);
if (from.pathname === "/work") {
event.viewTransition.types.add("from-index");
}
});Check event.viewTransition first in both handlers. It is null whenever the navigation was not eligible — and in pagereveal, also when the old document skipped the transition.
Two back/forward-cache details the spec's own examples call out. pagereveal fires on initial load and on reactivation from bfcache as well as on a transition, so that first guard is doing real work. And if a pageswap handler adds a class to prepare an element for capture, remove it again on viewTransition.finished — otherwise a bfcache restore brings the class back with it.
Waiting for the new page to be ready
A same-document transition tells the browser when the new state is stable: the callback you pass to startViewTransition(). A cross-document transition is declarative, so there is no callback. The browser instead uses the render-blocking mechanism to decide when the new document has settled.
That gives you three levers, all of them existing HTML:
| Lever | Effect |
|---|---|
<link rel="stylesheet"> | Render-blocking by default |
<script blocking="render"> | Delays the transition until that script has run |
<link rel="expect" href="#main" blocking="render"> | Delays it until that element has been seen and parsed |
rel="expect" is the interesting one: it lets you say "do not start the animation until the main article is actually in the DOM", which is what stops a transition landing on an empty shell.
At the time of writing, rel="expect" ships in Chrome 124 and nowhere else, and blocking="render" on scripts ships in Chrome 105 and Safari 18.2. Treat both as Chromium-first refinements, not as load-bearing.
The spec also warns against overdoing it: "overusing the render-blocking mechanism could make it so that the old state remains frozen for a long time, resulting in a jarring user experience". Blocking on one element is a reasonable bet. Blocking on four is a stall.
Browser support
At the time of writing, from browser-compat-data:
| Feature | Chrome / Edge | Safari | Firefox |
|---|---|---|---|
@view-transition (cross-document) | 126 | 18.2 | Not supported |
pagereveal | 123 | 18.2 | Not supported |
pageswap | 124 | 18.2 | Not supported |
view-transition-class | 125 | 18.2 | 144 |
view-transition-name: match-element | 137 | 18.4 | 144 |
link rel="expect" | 124 | Not supported | Not supported |
Same-document view transitions — document.startViewTransition() — are a different feature and became Baseline newly available in October 2025, with Firefox 144. Cross-document transitions are the part Firefox has not shipped.
That gap costs you nothing. In a browser without support, @view-transition is ignored and the navigation happens the way navigations have always happened. This is the rare feature that is an enhancement by construction, with no fallback to write.
FAQ
Why does nothing happen when I reload the page?
Reloads are excluded by the specification, as are navigations typed into the address bar or triggered from a bookmark. Click a link instead.
Do both pages really need the CSS?
Yes. The navigation descriptor must be present on both the old and the new document, and its initial value is none.
Does this work from HTTP to HTTPS, or between subdomains?
No. The two documents must be same-origin, and the navigation must not include a cross-origin redirect.
Can I use this with a static site generator?
That is the best case for it. Cross-document transitions were designed to give multi-page sites the polish that previously required a client-side router — no hydration, no route table, no JavaScript bundle.
How do I stop the animation for people who prefer reduced motion?
Nest the whole at-rule inside @media (prefers-reduced-motion: no-preference). The spec explicitly allows @view-transition inside a conditional group rule.
Sources
- CSS View Transitions Module Level 2 — CSS Working Group editor's draft, for the opt-in conditions, the
navigationandtypesdescriptors, the lifecycle and the render-blocking guidance @mdn/browser-compat-data— per-browser version data- web-features — Baseline status for same-document view transitions
More to read

Design · · 7 min read
CSS anchor positioning: tooltips and menus that follow their trigger
Anchor positioning works in Chrome, Safari and Firefox. The properties you need, the flip-when-it-does-not-fit rule, and the repeated-name bug.
- Design
Design · · 6 min read
@scope is Baseline: scoped CSS without the class-name gymnastics
@scope became Baseline newly available in March 2026. The donut, where scope proximity really sits in the cascade, and why it is not nesting.