Component
page-transition
Demo
This page is the demo. The docs site uses page-transition for its own
navigations, so the transition you see moving between pages here is the
CSS below, running on this site.
Try it: open a component from the index and come back. The header holds still while the content changes underneath it.
Source
Copy both files, or run npx nojsui add page-transition.
Browser support
Not yet in every major engine. The @supports fallback is what most visitors will get.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| Cross-document view transitions | limited | 126 | 126 | — | 18.2 | 126 | — | 18.2 |
Usage
Opts a multi-page site into animated navigations. Two files, no script, and it applies to every same-origin navigation on the site at once.
@view-transition {
navigation: auto;
}
That alone gets you a cross-fade. Naming the parts that persist is what makes it feel like one site rather than two pages:
<header class="sk-page-transition__header">…</header>
<main class="sk-page-transition__main">…</main>
Put the same two elements on every page. The header is held in place while the main region moves, so the chrome appears to stay put and the content changes underneath it.
A view-transition-name must be unique per document. Two elements sharing
one name aborts the entire transition, and the only symptom is that nothing
animates — no console error, no warning. If you need to map your own elements,
edit the two selectors in page-transition.css rather than adding more
elements with these classes. This is the first thing to check when a transition
mysteriously stops working.
Single-page apps
@view-transition { navigation: auto; } only fires on a cross-document
navigation — a real request for a new HTML document. It does nothing in React
Router, Next, Remix, SvelteKit, or any other client-side router, because those
routers swap content within one document; there is no document navigation for
the browser to hook. That excludes a large share of the people likely to want
this component.
The CSS half is identical. The view-transition-name assignments on the
header and main region in page-transition.css work unchanged inside an SPA.
Only the trigger differs: on a real navigation the browser calls
document.startViewTransition() itself; inside an SPA, the router has to call
it, because the route change is JavaScript state, not a document load.
Some routers give you that call as close to a one-liner:
- React Router: add
viewTransitionto a<Link>—<Link to="/about" viewTransition>— and the router wraps the navigation indocument.startViewTransition()for you. - Next.js: has experimental view-transition support in the App Router, but the flag name and API have been in flux and this file is not a reliable place to pin one down — check Next’s own docs for the current shape rather than trust a name here.
- SvelteKit: the documented pattern is the
onNavigatelifecycle hook, where you calldocument.startViewTransition()yourself inside it. That is a few lines of app code rather than a single prop — confirm the current signature against SvelteKit’s docs before shipping it.
Whichever router is in play, the split is the same: the framework supplies
the trigger, the kit supplies the animation. page-transition does not
cover SPA navigations on its own, and nothing above should be read as
claiming it does — the CSS is necessary but not sufficient without a router
that calls startViewTransition().
That split is not a corner this component cut. Anything that needs a
trigger from application code is outside a zero-JS kit — the same boundary
ADR 0028 draws for the magnetic button (no CSS surface exposes pointer
position) and ADR 0017 draws for the range’s value bubble (no CSS surface
exposes a live form value). A router calling startViewTransition() is
application code by definition; the kit’s job stops at giving that call
something worth animating.
How it works
@view-transition { navigation: auto; } tells the browser to capture the old
document, load the new one, and animate between the two snapshots. Elements
with a view-transition-name are captured separately and animated as their own
layer, so the browser morphs them from their old box to their new one; anything
unnamed goes into the single root snapshot and cross-fades.
There is no script anywhere in this, and no framework router. It is the browser doing the navigation it was always doing, with two extra frames on either side.
It has no root element
Every other component in this kit is a class on a box. This one’s styles land
on ::view-transition-* pseudo-elements, which the browser generates on the
document root during a navigation — there is nothing to hang a
.sk-page-transition class on.
That has one consequence for the theming contract (ADR 0011): with no root,
there is nowhere to resolve the public knobs into --_ aliases, so the
var(--sk-page-transition-*, <token>) reads happen directly in the rules. The
contract still holds in every way that matters — the properties are read and
never declared, each has a token fallback, and a consumer’s :root override
still reaches them, because the view-transition pseudo tree inherits custom
properties from the document root.
The global reduced-motion net does not cover this component
base.css clamps animation under prefers-reduced-motion: reduce with *,
*::before and *::after. The universal selector does not match
pseudo-elements, so ::view-transition-* matches none of those. Every other
component in the kit inherits that protection even if its own CSS forgets it;
this one does not, and handles reduce itself.
The treatment is CLAUDE.md rule 4 read literally — “reduced to opacity-only
under reduce” — and a cross-fade already is opacity-only:
- Under
no-preference: cross-fade, plus the main region sliding a short distance. - Under
reduce: the cross-fade only.
Keeping the fade is deliberate. Switching transitions off entirely would restore the hard cut between documents, which is a harsher change of scene than a dissolve, so it would make the accommodation worse than the default.
Keyboard contract
None. The component adds no controls and changes no focus behaviour; a navigation moves focus exactly as it did before, because it is the same navigation.
| Key | Behaviour |
|---|---|
Enter on a link | Navigates, as normal. The transition is a paint-level effect over the top |
| Any other key | No effect |
Verified manually in VoiceOver and NVDA: yes — neither announces anything new, which is correct: the transition is not in the accessibility tree.
Accessibility notes
The transition is a paint-level effect. It generates pseudo-elements, not DOM, so the accessibility tree is identical to what it was without the component and screen readers announce the new page exactly as they did before.
The reduced-motion handling above is the substantive accessibility work here, and it is the part with no safety net — see “How it works”.
One thing to watch that is not this component’s doing: if your header is
visually identical across pages but semantically different (a different
aria-current, a different heading level), the morph will make it look
continuous while assistive technology correctly reports a change. That is not a
bug in the transition, but it is a good reason to keep the named regions
genuinely the same thing on both pages.
Degradation
| Feature used | Baseline status | Behavior without it |
|---|---|---|
@view-transition (cross-document) | limited — Chrome/Edge 126+, Safari 18.2+, no Firefox | The at-rule is unknown and dropped. Navigation is an ordinary hard cut — exactly what the site did before adding this. |
This is the cleanest fallback in the kit and the reason the component is worth shipping with a limited badge: there is no fallback to write, nothing to hide, and nothing a Firefox user loses that they had a moment ago. The component is purely additive.
Theming
| Property | Default | Controls |
|---|---|---|
--sk-page-transition-duration | --sk-motion-base | How long the transition runs |
--sk-page-transition-easing | --sk-ease-out | Its easing curve |
--sk-page-transition-distance | --sk-space-md | How far the main region slides; ignored under reduce |