Skip to content

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.

Not shown in an iframe: a cross-document transition needs a real navigation between two documents, and a demo frame has neither. In Firefox — and under reduced motion — following these links is an ordinary navigation, which is the component's fallback.

Source

Copy both files, or run npx nojsui add page-transition.

Source for page-transition
<!-- .sk-page-transition — the markup side of a cross-document view transition.

     There is no widget here. This is the shape every page of the site shares:
     the parts that should persist across a navigation carry a class, and the
     CSS gives each one a view-transition-name. Put the same two elements on
     every page and the browser morphs them across instead of dissolving them.

     A view-transition-name must be UNIQUE per document. Two elements sharing
     one aborts the whole transition, and the only symptom is that nothing
     animates — so map your own elements by editing the two selectors in
     page-transition.css rather than by adding more elements with these
     classes.

     Nothing here is interactive and nothing needs ARIA: these are ordinary
     landmarks, and the transition is a paint-level effect the accessibility
     tree never sees. -->
<header class="sk-page-transition__header">
  <p class="sk-page-transition__brand">example.site</p>

  <nav class="sk-page-transition__nav" aria-label="Example">
    <a href="#" aria-current="page">Overview</a>
    <a href="#">Pricing</a>
    <a href="#">Docs</a>
  </nav>
</header>

<main class="sk-page-transition__main">
  <h3 class="sk-page-transition__title">The header stays; the page changes</h3>

  <p class="sk-page-transition__text">
    The header is named, so the browser treats it as the same element on both
    documents and holds it in place. The main region is named separately and
    fades a short distance, so the navigation reads as movement rather than a
    dissolve.
  </p>

  <p class="sk-page-transition__text">
    In Firefox, and in any engine that does not know
    <code>@view-transition</code>, the at-rule is dropped and navigation is
    ordinary — the same hard cut the site had before. Under
    <code>prefers-reduced-motion: reduce</code> the movement goes and the
    cross-fade stays.
  </p>
</main>
/* .sk-page-transition — animated navigations between real documents.

   `@view-transition { navigation: auto; }` opts the whole document in; the
   browser then cross-fades between the old page and the new one on every
   same-origin navigation. `view-transition-name` on the parts that persist
   makes those morph across instead of dissolving with everything else.

   THREE THINGS ABOUT THIS FILE ARE UNLIKE EVERY OTHER COMPONENT:

   1. It has no root element. These styles land on ::view-transition-*
      pseudo-elements, which the browser generates on the document root during
      a navigation — there is nothing to put a class on. So the public knobs
      are read directly in the rules below rather than resolved once into --_
      aliases on a root (ADR 0011). Still read and never declared, still always
      with a token fallback, and consumers are unaffected: the view-transition
      pseudo tree inherits custom properties from the document root, so a
      :root override reaches it.

   2. base.css's reduced-motion clamp does NOT reach here. That net is spelled
      `*`, `*::before`, `*::after`, and the universal selector does not match
      pseudo-elements. Every other component is protected even if its own CSS
      forgets; this one is not, so `reduce` is handled explicitly below.

   3. Nothing is needed for the fallback. An engine that does not know
      @view-transition drops the at-rule and navigates normally — which is
      what the site did before. Firefox has no cross-document view transitions
      at any version (support.json).

   Specs:
   - @view-transition ....... https://drafts.csswg.org/css-view-transitions-2/#view-transition-rule
   - view-transition-name ... https://drafts.csswg.org/css-view-transitions-1/#view-transition-name-prop
   - ::view-transition ...... https://drafts.csswg.org/css-view-transitions-1/#pseudo-root */

@view-transition {
  navigation: auto;
}

/* The parts that persist across pages. A name must be UNIQUE per document —
   two elements sharing one aborts the entire transition, and the browser
   reports that only by not animating. Map your own elements by changing these
   two selectors; do not add a third with a name already in use. */
.sk-page-transition__header {
  view-transition-name: sk-page-header;
}

.sk-page-transition__main {
  view-transition-name: sk-page-main;
}

/* The cross-fade. Applied unconditionally — it is opacity-only, which is what
   CLAUDE.md rule 4 permits under `reduce`, and it is gentler than the hard cut
   that switching transitions off would restore. */
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: var(--sk-page-transition-duration, var(--sk-motion-base));
  animation-timing-function: var(--sk-page-transition-easing, var(--sk-ease-out));
}

@media (prefers-reduced-motion: no-preference) {
  /* Movement, and only here. The main region slides a short distance while it
     fades, so the navigation reads as forward motion rather than a dissolve.
     The header is named but deliberately left un-animated: it is the same
     element on both pages, so the browser morphs it in place and it appears to
     stay put while the page changes underneath — which is the effect. */
  ::view-transition-old(sk-page-main) {
    animation-name: sk-page-transition-out;
    animation-duration: var(--sk-page-transition-duration, var(--sk-motion-base));
    animation-timing-function: var(--sk-page-transition-easing, var(--sk-ease-out));
  }

  ::view-transition-new(sk-page-main) {
    animation-name: sk-page-transition-in;
    animation-duration: var(--sk-page-transition-duration, var(--sk-motion-base));
    animation-timing-function: var(--sk-page-transition-easing, var(--sk-ease-out));
  }

  @keyframes sk-page-transition-out {
    to {
      opacity: 0;
      translate: 0 calc(-1 * var(--sk-page-transition-distance, var(--sk-space-md)));
    }
  }

  @keyframes sk-page-transition-in {
    from {
      opacity: 0;
      translate: 0 var(--sk-page-transition-distance, var(--sk-space-md));
    }
  }
}

Browser support

Limited availability

Not yet in every major engine. The @supports fallback is what most visitors will get.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
Cross-document view transitionslimited12612618.212618.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 viewTransition to a <Link><Link to="/about" viewTransition> — and the router wraps the navigation in document.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 onNavigate lifecycle hook, where you call document.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.

KeyBehaviour
Enter on a linkNavigates, as normal. The transition is a paint-level effect over the top
Any other keyNo 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 usedBaseline statusBehavior without it
@view-transition (cross-document)limited — Chrome/Edge 126+, Safari 18.2+, no FirefoxThe 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

PropertyDefaultControls
--sk-page-transition-duration--sk-motion-baseHow long the transition runs
--sk-page-transition-easing--sk-ease-outIts easing curve
--sk-page-transition-distance--sk-space-mdHow far the main region slides; ignored under reduce