Transitions

Built-in page transitions, where to set them, and how to define your own with nothing but a name and CSS.

Navigation animates by default. The framework ships four built-in transitions, lets you set them at three levels, and makes custom ones a matter of naming an animation and styling four CSS phases — no registration, no JavaScript.

Built-ins

  • push — iOS-style: the new page slides in from the right while the one behind shifts back and dims. The default.
  • slide — both pages travel: the new one slides in from the right as the current one slides out to the left.
  • fade — a crossfade, one layer at a time.
  • none — instant.

Set them at three levels

The call site wins, then the page, then the surface:

router.push("plans", { transition: "none" });   // one navigation
export const transition = "fade";               // one page (top of its file)
export default definePaywall({ transition: "slide" });   // whole surface

Going forward uses the incoming page's transition; going back uses the leaving one's — so a page always leaves the way it arrived.

Tune the built-ins

Three CSS variables adjust timing and feel without replacing anything:

:root {
  --sw-transition: 500ms;   /* duration */
  --sw-ease: cubic-bezier(0.28, 0.4, 0.08, 1);
  --sw-stack-dim: 0.925;    /* how much the page behind dims (the default) */
}

All motion respects prefers-reduced-motion automatically — with reduced motion on, the router settles instantly.

Custom transitions

A transition is just a name plus CSS. Name it anywhere a transition goes, then style the four phases:

export const transition = "zoom";
@media (prefers-reduced-motion: no-preference) {
  [data-sw-transition="zoom"][data-sw-phase] {
    animation-duration: 420ms;
    animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
  }
  [data-sw-transition="zoom"][data-sw-phase="enter"]  { animation-name: zoom-enter }
  [data-sw-transition="zoom"][data-sw-phase="recede"] { animation-name: zoom-recede }
  [data-sw-transition="zoom"][data-sw-phase="leave"]  { animation-name: zoom-leave }
  [data-sw-transition="zoom"][data-sw-phase="return"] { animation-name: zoom-return }
}

@keyframes zoom-enter  { from { transform: var(--sw-from-transform, scale(0.85)); opacity: 0 } }
@keyframes zoom-recede { to   { opacity: 0; transform: scale(1.15) } }
@keyframes zoom-leave  { to   { opacity: 0; transform: scale(0.85) } }
@keyframes zoom-return { from { opacity: 0; transform: scale(1.15) } }

The four phases cover both directions of travel:

PhaseThe page is…
enterarriving on top
recedebeing covered as you go forward
leavedropping off the top as you go back
returncoming forward again as you go back

Rules for custom transitions

  • Always start from at var(--sw-from-transform, <your value>) — and --sw-from-filter for filters. The router fills these with a page's live position when a navigation interrupts an animation, so a spammed button picks the page up where it stands instead of snapping.
  • Wrap in prefers-reduced-motion: no-preference. With reduced motion on, the router settles instantly and your animation never runs.
  • Duration comes from your CSS. The page stays mounted exactly as long as its animation runs; don't declare a duration anywhere else.
  • Omit phases you don't want. They don't animate — that's how fade crossfades one layer at a time.

Bottom sheets over the flow

For a modal-feeling page — a last-chance offer, say — define a sheet transition: the page slides up while the one behind scales back and dims. Darken the container behind it in the same motion by reusing the framework's timing variables:

:root { --dim: 0.85; }

[data-sw-routes] {
  transition: background-color var(--sw-transition, 500ms)
    var(--sw-ease, cubic-bezier(0.28, 0.4, 0.08, 1));
}
[data-sw-routes]:has([data-sw-transition="sheet"][data-sw-phase]) {
  background-color: color-mix(in srgb, var(--bg) calc(var(--dim) * 100%), #000);
}

One --dim number drives both the page's brightness() and the backdrop, so they always match. Dismissing the sheet is router.back() — the page leaves the way it came. The abandonment offer example has the full recipe — see Examples.

Transitions vs. in-page animation

Animation libraries (Motion, plain CSS) animate inside a page. Moving between pages stays the router's job. Keep that line and spamming navigation can never fight your component animations — and remember that entry animations gate on presentation, never mount. See Lifecycle & events.

How is this guide?

On this page