Skip to content

Routing Animations

olegivanov edited this page Apr 27, 2026 · 1 revision

Routing Animations

Four architectural patterns for animating route transitions, all coordinated through the router so URL and UI stay in lock-step.

Why Router Coordination

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:

(Angular: injectRouteExit / injectRouteEnter.)

Four Approaches at a Glance

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

The Four Approaches

1. View Transitions API

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).

2. Route Animations (centralised CSS recipe)

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).

3. Page Animations (distributed per-page hook)

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/.

4. Motion Animations (library-driven)

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/.

How to Choose

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 motion for component animations: Motion Animations. Library is paid-for.

Per-Adapter Notes

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.md
  • examples/web/<adapter>/animation-examples/route-animations/README.md
  • examples/web/<adapter>/animation-examples/page-animations/README.md
  • examples/web/<adapter>/animation-examples/motion-animations/README.md

Common Edge Cases (Across All Approaches)

  • Skip-initial. First-load mount should not animate (no previous route). router.start() does not fire subscribeLeave; useRouteEnter skips when route.transition.from is 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.abort on the in-flight LeaveState. The handler's signal.aborted pre-check is built into useRouteExit; pass the same signal to 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 when getAnimations() returns []) keep the router unblocked when keyframes collapse to animation: none.
  • Direction-aware UI. state.context.browser.direction ("forward" | "back") is published by @real-router/browser-plugin; createDirectionTracker(router) from shared/dom-utils/ mirrors it onto <html data-nav-direction> for CSS-keyed slide variants. See browser-plugin.

See Also

Navigation

Home


Concepts


Getting Started


Router Methods

Lifecycle

Navigation

State

URL & Path

Events


Standalone API

Tree-shakeable functions — import only what you need.

Routes — getRoutesApi(router)

Dependencies — getDependenciesApi(router)

Guards — getLifecycleApi(router)

Plugin Infrastructure — getPluginApi(router)

For plugin authors, not for general use.

SSR / SSG


React / Preact / Solid / Vue / Svelte Integration

Provider

Hooks

Components

SSR Components & Hooks

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

DOM Utilities

Patterns


Subscription Layer (@real-router/sources)


Reactive Streams (@real-router/rx)


Plugins

Browser Plugin

Navigation Plugin

Hash Plugin

Memory Plugin

Lifecycle Plugin

Preload Plugin

Logger Plugin

Persistent Params

SSR Data

RSC Server

Validation

Search Schema

Utilities


Reference

Types

Error Codes

Clone this wiki locally