-
-
Notifications
You must be signed in to change notification settings - Fork 0
Routing Animations
Four architectural patterns for animating route transitions, all coordinated through the router so URL and UI stay in lock-step.
In a typical "subscribe to route changes and play an animation" setup, the URL flips first and the animation plays after — the user briefly sees the new URL with the old DOM. Real-router fixes this through promise-blocking subscribeLeave: a listener can return a Promise<void> and the router awaits it before committing the new state. URL changes only after the exit animation finishes.
await router.navigate("about") resolves only after the user can see the new route. This invariant is the foundation all four recipes below build on.
The two primary primitives:
-
useRouteExit(handler, options?)— wrapsrouter.subscribeLeavewith abort + same-route guards. Handler can return aPromise— router blocks on it. -
useRouteEnter(handler, options?)— fires once on nav-driven mount with{ route, previousRoute }. Skip-initial built in.
(Angular: injectRouteExit / injectRouteEnter.)
view-transitions/ |
route-animations/ |
page-animations/ |
motion-animations/ |
|
|---|---|---|---|---|
| Mechanism |
document.startViewTransition() + VT pseudos |
Centralised CSS @keyframes + useRouteExit
|
Per-page useRouteAnimation hook |
Animation library (motion / <Transition> / etc.) |
| Router coordination | Promise blocks pipeline | Promise blocks pipeline | Promise blocks pipeline | Promise blocks pipeline |
| Browser support | Chromium 111+, Safari 18+, Firefox 147+ | Every browser with CSS animations | Every browser with CSS animations | Every browser with WAAPI |
| Exit / entry timing | Always parallel crossfade | Sequential by default | Sequential per page | Sequential (mode="wait" / mode="out-in") |
| Per-route customisation | Free via view-transition-name
|
Free via data-route-anim attribute |
Per-page class names | Single page-level transition |
| Hero morph between routes | Free (matching VT names) | Manual via getBoundingClientRect + WAAPI |
Out of scope |
Free (layoutId in motion-react) |
| List FLIP | Free | Manual (≈80 LOC ghost recipe) | Local FLIP via view-local hook | Free in motion-react / library-dependent |
| External dependency | None | None | None |
motion / framework's transition primitive (~50 KB if external) |
| Code size | ~30 LOC utility + ~120 LOC policy | ~30 LOC helper + ~250-380 LOC across hooks | ~120 LOC hook + per-page binding | ~85-115 LOC App + per-element library props |
Browser-driven via document.startViewTransition(). Smallest code, biggest browser support gap (Firefox 145- has no API). Hero morphs and persistent-shell crossfades come free via matching view-transition-name pairs.
<RouterProvider router={router} viewTransitions>
<App />
</RouterProvider>::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 600ms;
}The viewTransitions prop wires up createViewTransitions(router) from shared/dom-utils/ — see View Transitions for the full integration story (CSS hero morphs, direction-aware slides, Angular-specific tick).
Cross-browser. Centralised animation factories called once at the top of App — each wraps useRouteExit with its own DOM recipe. Pages stay declarative — they only mark [data-route-root] / [data-flip-key] attributes; the factories find them via DOM queries.
function App() {
usePageAnimator(); // page-level fade/slide via [data-route-root]
useHeroMorph(); // products ↔ products.detail FLIP
useListFlip(); // sort/filter list reorder + ghost exits
return <RouteView>...</RouteView>;
}Trade-off: pays ~250-380 LOC across three factories for what View Transitions does in two CSS rules. Reward: works in Firefox 145-, custom timing per route via data-route-anim, full router coordination, no rendering suppression during playback.
See examples/web/<adapter>/animation-examples/route-animations/ for the full implementation across 6 adapters (React / Preact / Solid / Vue / Svelte / Angular).
Distributed: each page mounts useRouteAnimation(ref, { entryClass, exitClass }) in its own component. The hook is built on useRouteExit + useRouteEnter and is route-agnostic — pages declare their own class names.
function HomePage() {
const ref = useRef<HTMLDivElement>(null);
useRouteAnimation(ref, { entryClass: "fade-in", exitClass: "fade-out" });
return <div ref={ref}>...</div>;
}Encapsulated, simpler mental model, less boilerplate per new page after the hook exists. Trade-off: cross-page coordination needs shared state — hero morph between routes needs source-rect handoff that a per-page hook can't see.
See examples/web/<adapter>/animation-examples/page-animations/.
Router-coordinated via an animation library: motion (React/Preact), Vue's <Transition>, Svelte's transition:fly, Angular's signal-driven CSS classes — depends on the framework's first-class animation primitive.
// React with motion
<AnimatePresence mode="wait" onExitComplete={onExitComplete}>
<motion.div key={exitToken} exit={...}>
<RouteView>...</RouteView>
</motion.div>
</AnimatePresence>Library-native ergonomics: declarative props for hero morph (layoutId), list reorder (<motion.li layout>), drag/gesture support. Trade-off: bundle size (~50 KB for motion); availability of features depends on the framework's animation primitive (Vue's <Transition> is per-element only, no layoutId equivalent).
See examples/web/<adapter>/animation-examples/motion-animations/.
Need hero morph or persistent-shell crossfade?
YES ── modern browsers OK?
│ YES → 1. View Transitions (cheapest code)
│ NO → 2. Route Animations (manual FLIP, cross-browser)
│
NO ── per-page entry/exit, no cross-route coordination?
│ YES ── library ergonomics preferred?
│ NO → 3. Page Animations (library-free, distributed)
│ YES → 4. Motion Animations (declarative, library-driven)
│ NO → 2. Route Animations or 4. Motion Animations
Defaults that work for most apps:
- Greenfield, modern-browser-only: View Transitions. Smallest code, free hero morph.
- Cross-browser, simple page-level fades: Page Animations. ~120 LOC and you're done.
- Cross-browser, full choreography control: Route Animations. Pay the LOC, get the control.
-
Already using
motionfor component animations: Motion Animations. Library is paid-for.
All four recipes ship for all six adapters — React, Preact, Solid, Vue, Svelte, Angular. The useRouteExit / useRouteEnter primitives are identical across them; the differences are in the framework's idiomatic syntax for ref binding, signal access, and library integration.
| Adapter | "Library-driven" recipe uses |
|---|---|
| React |
motion (formerly Framer Motion) |
| Preact |
motion via preact/compat
|
| Solid | solid-motionone |
| Vue | Vue's built-in <Transition> (language feature, no external dep) |
| Svelte | Svelte's built-in transition: directives (language feature) |
| Angular | Signal-driven CSS classes (no @angular/animations, library-free) |
For full implementation details — including reduced-motion handling, abort safety on rapid clicks, query-only suppression, and skip-initial — see the example READMEs alongside the code:
examples/web/<adapter>/animation-examples/view-transitions/README.mdexamples/web/<adapter>/animation-examples/route-animations/README.mdexamples/web/<adapter>/animation-examples/page-animations/README.mdexamples/web/<adapter>/animation-examples/motion-animations/README.md
-
Skip-initial. First-load mount should not animate (no previous route).
router.start()does not firesubscribeLeave;useRouteEnterskips whenroute.transition.fromis undefined. -
Same-route navigations. Sort/filter/query-only changes (
route.name === nextRoute.name) skip the page-level animation by default. Opt out with{ skipSameRoute: false }when the same-route window is exactly what you want to animate (e.g. list FLIP on sort change). -
Abort safety. Rapid clicks fire
signal.aborton the in-flightLeaveState. The handler'ssignal.abortedpre-check is built intouseRouteExit; pass the samesignalto fetch / element.animate / library APIs that accept it for full cancellation propagation. -
Reduced motion. Honour
@media (prefers-reduced-motion: reduce)in CSS. The recipe-side fallbacks (Promise.allSettled([])resolving synchronously whengetAnimations()returns[]) keep the router unblocked when keyframes collapse toanimation: none. -
Direction-aware UI.
state.context.browser.direction("forward" | "back") is published by@real-router/browser-plugin;createDirectionTracker(router)fromshared/dom-utils/mirrors it onto<html data-nav-direction>for CSS-keyed slide variants. See browser-plugin.
- View Transitions — full reference for approach #1
- useRouteExit — primitive used by #2, #3, #4
- useRouteEnter — primitive used by #3 and #4
-
browser-plugin —
state.context.browser.directionfor direction-aware animations - Scroll Restoration — sibling DOM utility with the same opt-in adapter pattern as View Transitions
- View-Agnostic Design
- Core Concepts
- Navigation Lifecycle
- Guards
- Plugin Architecture
- Hash Fragment Support
- Accessibility (A11y)
- Server-Side Rendering
- Data Loading
- Streaming SSR
- SSR Cancellation
- RSC Integration
- Testing
- Glossary
Tree-shakeable functions — import only what you need.
- add (addRoute)
- remove (removeRoute)
- replace (replaceRoutes)
- clear (clearRoutes)
- get (getRoute)
- has (hasRoute)
- update (updateRoute)
- get (getDependency)
- getAll (getDependencies)
- set (setDependency)
- setAll (setDependencies)
- has (hasDependency)
- remove (removeDependency)
- reset (resetDependencies)
For plugin authors, not for general use.
- makeState
- buildState
- buildNavigationState
- forwardState
- getForwardState
- setForwardState
- matchPath
- setRootPath
- getRootPath
- navigateToState
- addEventListener
- getRouteConfig
- getOptions
- addBuildPathInterceptor
- extendRouter
- getTree
- React Integration Guide
- Preact Integration Guide
- Solid Integration Guide
- Vue Integration Guide
- Svelte Integration Guide
- Ink (Terminal UI) Integration Guide
- Desktop (Electron, Tauri) Integration Guide
- useRouter
- useRoute
- useRouteNode
- useNavigator
- useRouteUtils
- useRouterTransition
- useRouteExit
- useRouteEnter
- useRouteStore (Solid only)
- useRouteNodeStore (Solid only)
SSR-feature subpath —
@real-router/{adapter}/ssr. Symmetric across React/Preact/Solid/Vue/Svelte.
- Lazy (Svelte only — dynamic component import)
- Await — read deferred SSR data by key
- Streamed — Suspense-style boundary
- ClientOnly — server fallback → client children swap
- ServerOnly — server children, removed after hydration
- HttpStatusCode — render-time HTTP status declaration
-
HttpStatusProvider — provides sink to descendant
<HttpStatusCode> - useDeferred — read deferred Promise by key