Mobile

Screen Transition

A push/pop screen stack with an interactive left-edge back swipe, a parallaxing under-layer and a drag that stays cancellable until you let go.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronLeft } from "lucide-react"
import { cn } from "@/lib/utils"

/** Movement (px) before the gesture picks an axis. Under it, a press near the edge is still a tap. */
const AXIS_LOCK_PX = 8
/** How far in from the left edge (px) a press may land and still start a back swipe. */
const DEFAULT_EDGE_WIDTH = 24
const MAX_EDGE_WIDTH = 96
/** Fraction of the width the drag must cross to pop on release. */
const DEFAULT_THRESHOLD = 0.4
const MIN_THRESHOLD = 0.1

Installation

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

Prompt

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

Build a React + TypeScript + Tailwind "ScreenTransition" component: a mobile
push/pop navigation stack with an interactive edge-swipe back (lucide-react for
the chevron, no animation library, no router — the gesture layer is the point
and it has to be self-contained).

Contract
- Props: screens: ScreenTransitionScreen[] (the whole stack, root first);
  index?: number + defaultIndex?: number (default 0) + onIndexChange?:
  (index: number) => void; variant?: "push" | "cover" | "zoom" (default
  "push"); edgeWidth?: number (px, default 24, clamped 0..96, 0 removes the
  gesture); threshold?: number (fraction of the width, default 0.4, clamped
  0.1..0.9); onBackBlocked?: (index: number) => void; showHeader?: boolean
  (default true); label?: string for the stack's accessible name; className
  merged with cn(); remaining div props spread on the root; forwardRef to the
  root.
- ScreenTransitionScreen: { key: string; title?: string; action?: Slot;
  backLabel?: string; canGoBack?: boolean (default true); content: Slot }.
  A Slot is ReactNode | ((api) => ReactNode), where api is
  { index, count, push, back } — that is how a row inside a screen pushes and
  how a header action pops, without the consumer wiring state for the
  uncontrolled case.
- Controlled and uncontrolled: pass index to own the position, omit it and the
  component owns it. Either way onIndexChange fires with the index the stack
  WANTS, so a controlled parent can refuse a pop and the screen springs back
  instead of being stranded half-open.
- Index is clamped, never trusted: non-finite or out-of-range lands on the
  root, an empty screens array renders an empty frame rather than crashing.

Behavior
- Geometry is one pure function frameFor(rel, progress, parallax, scale, veil),
  where rel = screenIndex - activeIndex and progress runs 0 (top screen fully
  covering) to 1 (fully swiped off the right):
    rel > 0  -> translateX(100%)                       (pre-mounted, off right)
    rel = 0  -> translateX(progress * 100%)            (the screen under the finger)
    rel < 0  -> translateX(-parallax * (1 - q)%), scale(1 - (1-scale)(1-q)),
                veil opacity = veilMax * (1 - q), where q = progress for the
                screen directly beneath and 0 for anything deeper.
  Render calls it with progress 0 and the gesture calls it every frame, so both
  emit byte-identical strings.
- Variants are three geometries of ONE state machine: push
  { parallax 30%, scale 1, veil 0.5 } (iOS trailing under-layer), cover
  { parallax 0, scale 1, veil 0.68 } (nothing moves below, it is just
  revealed), zoom { parallax 10%, scale 0.92, veil 0.35 } (the layer below
  recedes as a card).
- The gesture is owned by the top screen itself, not by an overlay: pointer
  handlers live on the active <section>, and a press is only a candidate when it
  lands within edgeWidth px of the STACK's left edge (clientX minus the root's
  bounding rect). An absolutely positioned grab strip is the obvious
  implementation and the wrong one — it would sit over the back button and over
  the left edge of every row, swallowing their taps and their vertical scrolling.
  Pointer events only — one code path for mouse, touch and pen — with pointerId
  isolation so a second finger cannot take over a drag in flight.
- touch-action: pan-y on the active screen, and only while a back swipe is
  actually possible: vertical panning stays with the browser (the body still
  scrolls natively), horizontal panning is claimed by this component. Note the
  consequence and document it — a horizontally scrollable child inside a screen
  cannot opt back out, because touch-action intersects down the ancestor chain;
  set edgeWidth={0} for such a screen, or keep the rail out of the swipe band.
  Nothing here calls preventDefault, so no listener has to be non-passive.
- Claim rules: a press is not a drag. After 8px of movement, claim only if the
  movement is rightwards AND more horizontal than vertical; otherwise abandon
  the gesture for good, so a vertical flick that began near the edge can never
  leave a screen half-open. On claiming, re-baseline startX to the current
  position so the screen does not jump the 8px lock distance.
- setPointerCapture happens at the CLAIM, never at pointerdown. Capturing early
  would retarget the eventual click to the screen, and a plain tap in the edge
  band would stop activating the row under it. Capturing at the claim gets both:
  taps stay taps, and a real drag keeps receiving moves after the finger leaves
  the element it started on. The click that follows a claimed drag is swallowed
  once, in an onClickCapture on the root, and the flag is cleared by the next
  pointerdown on the ROOT as well (capture phase), not inside the gesture's own
  pointerdown: a touch drag fires no click at all, and it usually lands on the
  root screen, which starts no gestures — clear the flag anywhere narrower and
  it survives to eat the first real tap after every swipe back.
- An unclaimed drag record may be overwritten by the next press. It holds no
  pointer capture, so a mouse released outside the window delivers no pointerup,
  and a sticky record would block every later gesture. A claimed one may not.
- Nothing re-renders while the finger is down. Progress lives in a ref, is
  written to style.transform inside a single coalesced rAF, and transitions are
  suppressed by setting style.transitionProperty = "none" on every screen (and
  restored by setting it back to "", handing the property to the class). React
  never writes transitionProperty, so the two layers cannot disagree about who
  owns it — and because the drag writes exactly the strings React would have
  written, React's style diff never clobbers a drag.
- Release decides by displacement OR velocity: pop when the undamped progress
  is past `threshold` or the lightly smoothed velocity is over 0.35 px/ms
  (so a short fast flick pops and a long slow drag that stopped short does
  not). Order at release: clear the drag ref FIRST (one-shot guard, read and
  written synchronously in the handler), release capture, cancel the pending
  rAF, restore transitions, paint the resting layout for the CURRENT index, and
  only then request the new index. Painting the current layout first is what
  makes a cancelled drag and a refused pop settle identically: when the index
  really does change, React overwrites those values in the same task, so only
  the final ones are ever painted, and the transition still runs from wherever
  the finger left the screen.
- canGoBack: false is a refusal, not a dead end. The swipe still moves (30% of
  the finger, capped at 6% of the width) and springs back, the back button
  reports aria-disabled instead of the native attribute — the browser blurs a
  control the instant it becomes disabled, and this is a control the user may
  be standing on — Escape is refused too, and all three call onBackBlocked so
  the screen can explain itself.
- Keyboard: the header back button is a real 44px control, and Escape pops,
  handled on the stack's own onKeyDown with stopPropagation rather than a
  window listener (a window listener cannot tell which layer is on top, so one
  Escape with a popover open inside a screen would close both). The consumer's
  onKeyDown runs first and event.defaultPrevented is respected.
- Focus: screens that are not on top get the `inert` attribute, not just
  aria-hidden — a screen parked off-screen still holds real buttons, and Tab
  reaching them is how a keyboard user ends up typing into something invisible.
  Because inert drops focus onto <body>, every index change hands focus to the
  new top screen (tabIndex={-1} on the section, so its aria-label is announced)
  — but only when focus was already inside the stack or had just been lost that
  way, so a controlled parent switching screens never steals the caret from
  elsewhere on the page.
- Announcement: one polite aria-live region carrying "<title>, screen N of M",
  derived from the index rather than pushed from a handler, so a controlled
  navigation announces exactly like a swipe. Silent during the drag — announcing
  pixels is a screen-reader storm.
- prefers-reduced-motion is subscribed through matchMedia (useSyncExternalStore,
  unsubscribed on unmount), never read once: parallax becomes 0, scale becomes
  1, the settle transitions are dropped. The drag itself keeps tracking the
  finger, because direct manipulation is information, not decoration.
- Cleanup: the rAF is cancelled and pointer capture released on unmount and at
  the end of every gesture; lostpointercapture and pointercancel both route to
  the same finish path, which is re-entrant-safe because the drag ref is already
  null. Only the top screen starts and tracks a gesture, but every screen can
  end one, and an index change arriving from outside mid-drag ends it too — a
  gesture must never be left hanging because the stack moved underneath it.

Rendering & styling
- Semantic tokens only: screens bg-background with shadow-2xl, the frame
  bg-muted (visible only behind a scaled-down zoom under-layer), header
  border-b, back button text-[13px] with hover:bg-muted and
  focus-visible:ring-2 ring-ring, aria-disabled:text-muted-foreground. No hex,
  no rgb(), no palette names. Monochrome by construction — depth comes from
  motion and dimming, not colour.
- The dimming veil is bg-background at a computed opacity, NOT a black wash: it
  fades the layer below toward the page's own backdrop, which reads as
  receding in light and dark alike, whereas black-over-dark does nothing and
  white-over-light washes out.
- Safe area: the header pads with var(--safe-area-inset-top,
  env(safe-area-inset-top, 0px)) (the same pattern for bottom on the scroll
  area and left/right on the screen), so the title bar clears the notch and the
  content clears the home indicator. Reading the custom property first lets a
  device-frame preview or a test simulate insets. The padding sits on a wrapper
  around the 48px header row, so the inset adds to the bar instead of eating it.
- The root is relative isolate overflow-hidden rounded-2xl border with a default
  height; screens are absolutely positioned siblings with zIndex = arrayIndex + 1
  — array order IS stacking order, which is what lets a popped screen animate out
  ON TOP of the one it uncovers instead of vanishing behind it.

Customization levers
- Presentation: `variant` is the whole look — push for hierarchy, cover for a
  full-screen step, zoom for a card stack. Each is three numbers (parallax,
  scale, veil) in one table at the top of the file; add a fourth entry rather
  than branching in the render.
- Gesture feel: `edgeWidth` (0 disables the gesture and keeps everything else),
  `threshold`, the 0.35 px/ms fling constant, the 8px axis lock, the 300ms /
  cubic-bezier(0.32, 0.72, 0, 1) settle, and the 6% refusal cap are all single
  constants.
- Chrome: showHeader={false} drops the built-in bar entirely (build your own
  inside content — Escape still pops), `action` puts one control in the
  trailing slot, `backLabel` overrides the accessible name when the previous
  title is not a good sentence.
- Height: the root ships with a fixed preview height; swap it for h-dvh (plus
  viewport-fit=cover in your viewport meta) when this is the whole app shell.
- Density: the 48px header row, 44px back button and text-[15px] title follow a
  compact mobile scale; scale them together, not individually.
- Wiring it to a router: keep `screens` derived from your route stack and drive
  index from the router, treating onIndexChange as "the user asked to go back"
  — call router.back() there and let the stack follow the route.

Concepts

  • Edge band, not an edge element — the back swipe may only start within edgeWidth of the screen's left edge, but that band is a coordinate test inside the screen's own pointerdown, not a strip laid over it, and pointer capture is taken at the moment the drag is proven rather than at the press. Both shortcuts (an overlay, capturing early) quietly turn the band into a dead zone that eats the back button, the left edge of every row and any vertical scroll that started there. The test costs one subtraction and steals nothing.
  • Interactive dismissal — the difference between a transition and a gesture. A transition plays after you decide; this one is the decision: the screen sits under the finger, the layer below moves in the same frame, and you can change your mind at 90% and get everything back. Any implementation where the pop fires on touchend and then animates has thrown away the feature.
  • Velocity settling — a flick and a slow drag ending on the same pixel mean different things, so the release reads a smoothed px/ms as well as the distance. Distance alone forces users to drag halfway across a 400px screen to go back.
  • Cancellable by construction — the release path always paints the resting layout for the current index before asking for a new one, so "the user let go too early" and "the controlled parent refused the pop" take exactly the same code path. Nothing can be left stranded half-open.
  • Render owns rest, the gesture owns motion — one pure geometry function serves both, so React's style diff writes the same strings the drag would have and never fights it, and the drag itself costs zero re-renders no matter how heavy the screen it is dragging.
  • Inert, not just hidden — an off-screen screen is still a live subtree full of focusable controls. aria-hidden alone leaves them in the tab order, which is how a keyboard user ends up typing into a screen nobody can see; inert removes them from focus and the accessibility tree at once, which is exactly why the component then has to hand focus to a deliberate successor.

On This Page