Navigation

Page Transition

A route swap wrapper: it holds the outgoing view until the incoming one is ready, animates the change, puts each route's scroll offset back and moves focus into the new view.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { flushSync } from "react-dom"
import { cn } from "@/lib/utils"

/**
 * How the duration budget splits. Leaving is the smaller half: a view on its way
 * out has nothing left to read, while the incoming one carries the content the
 * eye lands on.
 */
const EXIT_SHARE = 0.4
/** Longest swap the component will run. Past this a transition reads as a stall. */
const MAX_DURATION = 2000

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/page-transition.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "PageTransition" component — the wrapper an
app shell puts around its routed view. React and react-dom only: no animation
library, no router coupling.

Contract
- forwardRef<HTMLDivElement, PageTransitionProps> extending
  Omit<React.HTMLAttributes<HTMLDivElement>, "children">; "use client".
- routeKey: string (required) — identity of the view on screen: a pathname, a
  wizard step, a tab value. Changing routeKey is the ONLY thing that starts a
  swap; new children under the same key re-render the current view in place.
- children: ReactNode (required).
- variant = "fade" | "slide" | "rise" | "scale" (default "fade");
  direction = "forward" | "backward" (default "forward", ignored by fade and
  scale); duration = 320 (ms, the TOTAL budget, clamped to 0..2000, where 0 means
  instant); distance = 16 (px, slide and rise only, clamped to 0..240).
- ready = true — false holds the outgoing view instead of swapping to a
  half-built one; maxHold = 4000 — ms after which the swap goes through anyway.
- restoreScroll = true; scrollRef?: React.RefObject<HTMLElement | null> — the
  scroll container that owns the views; omit it and the page is the scroller.
- focusTarget = "heading" | "container" | "none" (default "heading");
  headingSelector = "[data-page-heading], h1, h2".
- announce?: string — pushed to the polite live region after the swap.
- viewTransition = false — opt in to the browser's View Transitions API.
- onSwapStart / onSwapEnd: (info: { from, to, engine, timedOut }) => void.
  Deliberately NOT named onTransitionStart / onTransitionEnd: those are real DOM
  events on every div, so a CSS transitionend bubbling out of a child would call
  the consumer's handler with a payload of the wrong shape.
- Also export supportsViewTransitions(): boolean, so a consumer can feature-detect
  from an effect and decide whether to opt in.

Behavior
- Phase machine: idle -> holding -> exiting -> entering -> idle. Only routeKey
  moves it; a re-render never does.
- Hold: while routeKey has changed and ready is false, keep rendering the LAST
  COMMITTED children of the route on screen — a ref that simply stops updating
  the moment the two keys disagree — and set aria-busy. The incoming children sit
  in props the whole time and are just not shown, which is what makes the hold
  free: nothing is snapshotted, cloned or portalled.
- Give up: a hold that reaches maxHold swaps anyway and reports timedOut: true.
  A wrapper that waits forever is an app that looks frozen.
- Three engines, decided per swap:
  * instant — prefers-reduced-motion (subscribed through useSyncExternalStore
    over matchMedia, server snapshot "motion allowed", never read during render)
    or duration 0. One commit, no animation, everything else unchanged.
  * view-transition — only when viewTransition is on AND the API exists. The
    browser holds a snapshot of the old frame while React swaps the DOM inside
    flushSync, which is the only way the commit lands inside that window. The
    browser owns the animation here, so variant / duration / distance do not
    apply; you style it with your own ::view-transition-old and
    ::view-transition-new rules. It is DOCUMENT scoped, which is why it is off by
    default: a wrapper nested inside a card would cross-fade the whole page.
    Reach the API through an `unknown` cast rather than the DOM typings, so the
    file still compiles in a project on an older lib.dom.
  * css — the default. Exit for 40% of the budget, commit, enter for the other
    60%. Exits are the shorter half: a view on its way out has nothing left to
    read, while the incoming one carries the content the eye lands on.
- Timing maths: exit = round(duration * 0.4), enter = duration - exit. The
  setTimeout values and the CSS animation durations come out of those same two
  numbers, so they cannot drift apart. Exit easing cubic-bezier(0.4, 0, 1, 1)
  (accelerate away), enter cubic-bezier(0, 0, 0.2, 1) (decelerate into place).
- Variants are two transforms fed into ONE pair of keyframes through custom
  properties (--pt-exit, --pt-enter): slide is translateX(+/-distance) and rise
  is translateY(+/-distance), with the sign flipped by direction so backward is
  the exact mirror of forward; scale exits at 1.01 and enters from 0.98; fade
  sets neither. A fifth variant is one more line in that map, not a new keyframe.
- Interruption: a second routeKey change during the exit does NOT queue a second
  swap. The exit timer reads the CURRENT routeKey when it fires — a one-shot ref
  read — so two changes inside one exit cost one swap. A route that comes back to
  where it started before the exit ends commits nothing and simply settles back
  in, reporting from === to.
- Height floor: on the css path, pin the outgoing height as an inline min-height
  before the exit and release it when the enter finishes, so the content below
  cannot jump while the two views trade places.
- Scroll memory: a Map of routeKey -> offset. Write the outgoing offset just
  BEFORE the DOM changes and read it back in a layout effect right after, so the
  restore lands before paint rather than as a visible correction. An unvisited
  route starts at 0, like every router. Bound the map at ~30 entries
  (delete-then-set, so the newest entry is the most recently visited and eviction
  drops genuinely stale ones) instead of leaking one entry per URL ever visited.
- Focus, not just scroll: the swap is a React key change, so the outgoing tree
  really unmounts and whatever had focus goes down with it. After the commit,
  find the heading inside the wrapper (querySelector wrapped in try/catch,
  because an invalid consumer selector must not take the page down from inside a
  layout effect) and fall back to the wrapper itself. Lend it tabindex="-1" only
  if it declares none, and take the attribute back on its blur. Always
  focus({ preventScroll: true }) — a plain focus() drags the view to the top edge
  and undoes the restore that just ran. If the element refuses focus (a heading
  inside a hidden subtree, detected by document.activeElement right after), fall
  back to the container.
- focusTarget="none" is a promise not to STEAL focus, not a promise to drop it:
  when the element that had focus was inside the tree that just unmounted, the
  container takes it anyway. Focus never lands on the body element.
- Announcement: with focus moving, the new heading announces itself and a live
  region would say everything twice, so the region stays empty unless announce is
  passed. With focusTarget="none" it falls back to announcing the routeKey,
  because a route change nobody hears is a bug. Clear the message on a timer:
  an identical second announcement is no DOM change at all, and would never be
  read out.
- Cleanup: the exit timer, the enter timer, the announcement timer, the hold
  timer, the matchMedia subscription, the borrowed tabindex with its blur
  listener, and any running view transition (skipTransition, or the document
  stays frozen for a page this component no longer owns) are all released on
  unmount, and again whenever a new swap supersedes the one in flight. A ref
  holding the pair in flight is read and written synchronously inside the same
  handler, so a re-render — ready flipping true right after a timeout, say — can
  never start the same swap twice.

Rendering and styling
- Semantic tokens only, and in practice the wrapper paints nothing of its own: no
  background, no border, no padding. It moves and fades whatever the view already
  looks like, and every visual decision arrives through className, merged with
  cn(). Views that need a surface use bg-card / border / text-muted-foreground
  themselves.
- The two keyframes ship through a React 19 hoisted style tag with an href and a
  precedence, deduped across every instance — no Tailwind config edits.
- data-state="idle|holding|exiting|entering" and
  data-engine="instant|css|view-transition" sit on the root, so a consumer can
  style the wait (data-[state=holding]:opacity-60) or assert the path in a test.
- aria-busy while holding. The live region is a role="status" aria-live="polite"
  sr-only span after the content.
- Reduced motion removes the movement, not the swap: the animation is dropped the
  instant the preference flips, mid-swap included, while the commit, the scroll
  restore and the focus move all still happen.
- There is no keyboard map, because the wrapper owns no controls. What it does
  own is where the keyboard ENDS UP: exactly one deliberate landing spot per
  swap, and never the body element.

Customization levers
- Motion personality: variant picks the shape, duration the pace (240-320 reads
  as an app, 400-500 as a marketing site), distance the travel (12-16 for panels,
  28-40 for full pages). direction is what makes Back read as Back — drive it
  from your router's history delta, or from the button that was pressed.
- The split: 0.4 is the one number to touch for a different rhythm. 0.5 reads as
  a hard cut, 0.3 as a quick clear-out with a long settle.
- Scope: pass scrollRef when the views live inside a scrolling panel, leave it
  off when the wrapper drives the page, and set restoreScroll={false} for wizards
  and tab panels, where "where you were" is not a thing.
- Landing: focusTarget="container" for views with no heading, headingSelector
  when they have one under your own attribute, and style the arrival yourself
  with a [tabindex="-1"]:focus rule — the component moves focus there, you decide
  what landing looks like.
- Readiness: wire ready to your data layer's loading flag to hold the old page
  instead of flashing a skeleton, and lower maxHold to fail fast on a slow route.
- The hold is invisible by default; add
  className="transition-opacity data-[state=holding]:opacity-60" when the wait
  should be seen.
- viewTransition={true} belongs at the app-shell level only, optionally behind
  supportsViewTransitions(); give the elements that should morph a
  view-transition-name in your own CSS.
- The wrapper writes min-height inline during a css swap and clears it when idle,
  so declare your own floor through className (min-h-*) rather than style.

Concepts

  • Hold until ready — the incoming view is in props the whole time; the wrapper simply keeps rendering the last committed children of the route on screen until ready flips. Holding an old page beats flashing a skeleton, and it costs nothing: no snapshot, no clone, no portal. The deadline is the other half of the idea — maxHold swaps anyway and reports timedOut, because a wrapper that waits forever is indistinguishable from a frozen app.
  • Per-route scroll memory — the offset of the view that is leaving is written just before the DOM changes and read back in a layout effect just after, so the restore happens before paint instead of as a visible jump. Unvisited routes start at the top, the map is bounded so a long session cannot leak an entry per URL, and focus({ preventScroll: true }) is what stops the focus move from undoing the whole thing.
  • Focus is the announcement — moving focus into the new view is what tells a screen reader the page changed; a live region on top of it would say everything twice. So the region stays silent while focus moves, and only speaks when focusTarget="none" takes that job away from it. Either way something is said, and either way focus lands somewhere deliberate rather than on body.
  • Three engines, one contract — reduced motion or duration=0 takes the instant path, an opted-in browser takes the native View Transitions path, everything else takes the CSS path. All three hold, remember the scroll, move focus and fire the same callbacks; they differ only in what the swap looks like, and data-engine says which one ran.
  • One swap per interruption — the exit timer reads the current routeKey when it fires rather than the one that started it, so three clicks inside one exit still cost exactly one swap. A route that returns to where it started mid-exit commits nothing and settles back in place, reporting from === to instead of a phantom navigation.
  • Reduced motion removes the movement, not the swap — under prefers-reduced-motion the animation is dropped the instant the preference flips, mid-swap included, while the commit, the scroll restore, the focus move and the announcement all still happen exactly as before.

On This Page