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 five 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.shift, the funnel step: the new page fades in as it drifts the last 44px into place, and the page it replaces is simply gone. One layer moves at a time, so a long flow never reads as a stack. Set it once inconfig.tsfor onboarding quizzes and web funnels.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
A handful of CSS variables adjust timing and feel without replacing anything. Set them on [data-sw-route], not :root. The router writes --sw-transition inline on the routes container, and an inline declaration shadows :root:
[data-sw-route] {
--sw-transition: 500ms; /* duration */
--sw-ease: linear(…); /* a spring; cubic-bezier(0.28, 0.4, 0.08, 1) is the fallback */
--sw-stack-dim: 0.925; /* how much the page behind dims (the default) */
--sw-transition-fade: 350ms; --sw-ease-fade: cubic-bezier(0.4, 0, 0.2, 1);
--sw-transition-shift: 420ms; --sw-ease-shift: cubic-bezier(0.2, 0, 0, 1);
}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 as long as its animation runs, capped at 5s (and falling back to 500ms when nothing measurable is declared); 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
How styling works in a paywall, plain CSS, the color-scheme class, the background color, insets and safe areas, and the platform stylesheet Superwall applies at serve time.