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 surfaceGoing 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:
| Phase | The page is… |
|---|---|
enter | arriving on top |
recede | being covered as you go forward |
leave | dropping off the top as you go back |
return | coming forward again as you go back |
Rules for custom transitions
- Always start
fromatvar(--sw-from-transform, <your value>)— and--sw-from-filterfor 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
fadecrossfades 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?
Pages & Navigation
Build multi-page paywalls, onboardings, and funnels with file-based pages and a stack router — no network between steps, no loading spinners.
Styling & Mobile Design
Dark mode, safe areas, scroll behavior, motion, and touch — the platform conventions that make a paywall feel native inside a webview.