Mobile

Swipe Pager

Full-width paged views under a horizontal drag — the deck tracks the finger 1:1, resists past the first and last page, commits on flick or distance, and reports position through a dot, bar or counter rail.

Preview in your theme

Loading preview…

"use client"

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

/** Movement (px) before the gesture picks an axis. Below it, a press is still a tap. */
const AXIS_LOCK_PX = 8
/** How long a committed — or refused — page turn takes to settle. */
const SETTLE_MS = 320
/** Fast out of the gate, long tail: the platform page-turn curve. */
const EASING = "cubic-bezier(0.32, 0.72, 0, 1)"
/** Flick speed (px/ms) above which direction beats distance. */
const FLING_VELOCITY = 0.4

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/swipe-pager.json

Prompt

Build a React + TypeScript + Tailwind "SwipePager" component (lucide-react for the two chevrons,
Pointer Events, no gesture library). It is a paged viewport: exactly one page fills the width, the
finger moves the whole deck, and the deck resists past the ends instead of stopping dead.

Contract
- export interface SwipePagerPage { key: string; label?: string; content: React.ReactNode }
- export type SwipePagerVariant = "dots" | "bar" | "counter" | "none"
- forwardRef<HTMLDivElement, SwipePagerProps>, props spread onto the root, cn() merges className.
- Props: pages (required), variant = "dots", page (controlled index), defaultPage = 0,
  onPageChange?(index), label = "Pages", previousLabel = "Back", nextLabel = "Next",
  safeArea = true.
- Controlled and uncontrolled both: `page` present means the parent owns the index and the component
  only ever *requests* a change; absent means it owns the index itself. Every requested index is
  clamped into 0..pages.length-1 and NaN falls back to 0 — a consumer number is never trusted.
- pages.length < 2 turns the whole feature off: no rail, no tab stop, no gesture. pages.length === 0
  renders an empty deck and no empty-state copy of its own.

Behavior
- Position is one float: `progress` in pages (2.5 = halfway between the third and fourth page). It
  lives in a ref and is written straight to the DOM as `translate3d(-progress * 100%, 0, 0)` on the
  track, never through state — a 60fps drag must not re-render every page. Because the transform is
  a percentage of a track that is exactly one viewport wide, resize and rotation need no measurement
  and no ResizeObserver.
- Gesture (Pointer Events only, one unified path for touch, mouse and pen):
  * pointerdown records the pointerId, the viewport width and the position the eye currently sees —
    read off the live computed transform (DOMMatrixReadOnly on getComputedStyle().transform), so
    grabbing the deck mid-settle picks it up where it is instead of jumping to where it was heading.
    A press starting inside [data-pager-no-swipe] is ignored, so sliders and horizontal controls keep
    their own drag.
  * Axis lock at 8px: the first decisive movement decides. More vertical than horizontal hands the
    whole gesture back to the browser for good (a diagonal flick down a scrolling page can never
    become half a page turn); more horizontal captures the pointer with setPointerCapture on the
    viewport and re-baselines the origin so the deck starts from 0 instead of jumping 8px.
  * The viewport carries `touch-action: pan-y pinch-zoom`, which is what makes it legal to never call
    preventDefault: vertical scrolling and page zoom stay with the platform, the horizontal axis is
    ours. Do not attach passive listeners and then cancel them.
  * Outside 0..last the movement is damped by an asymptotic rubber band (EDGE_LIMIT, 0.16 of a page
    of overshoot at most), so the end of the deck is felt rather than hit.
  * Velocity is a smoothed px/ms sample (v = v*0.3 + sample*0.7) so one jittery frame cannot fake a
    flick.
- Release: a flick faster than 0.4px/ms commits one boundary in that direction whatever the distance;
  otherwise the drag has to have crossed 28% of a page. Both are clamped into the deck, so a hard
  pull at either end springs back. A cancelled gesture (pointercancel, the system taking over)
  decides nothing and eases back to the current page.
- Refusal: a controlled parent that ignores onPageChange must not desync the view — bump an internal
  settle counter on every gesture end so the deck always re-settles onto whatever `page` currently
  is, whether that is the new index or the old one.
- Keyboard, on the viewport itself (tabIndex 0 when there are ≥2 pages): ArrowLeft / PageUp = previous,
  ArrowRight / PageDown = next, Home = first, End = last, each preventDefault-ed. Keys are ignored
  when the event started inside an input, textarea, select, contenteditable, slider, listbox or menu.
- ARIA: root group with aria-roledescription="pager" and aria-label; each page a group with
  aria-roledescription="page" and its label (falling back to "n of m"); an sr-only sentence wired via
  aria-describedby says the arrow keys work; a polite role=status region announces
  "Page 3 of 8: <label>" on arrival only — never during the drag. Off-screen pages are `inert` so Tab
  cannot walk into a page nobody can see; because an inert subtree drops focus onto <body>, track the
  last node focused inside the deck and hand focus to the viewport when its page goes inert.
- Rails, all made of real buttons (never a decorative indicator):
  * "dots": one 44px-tall button per page that jumps straight to it, active dot stretched into a
    pill; the buttons share the width when there are more dots than fit.
  * "bar": Back / Next either side of a progress track whose fill is painted by the same writer as
    the deck, so it tracks the finger continuously instead of jumping at commit.
  * "counter": chevron icon buttons around a tabular-nums "3 / 12" readout, for counts a dot rail
    cannot carry.
  * "none": no rail — for consumers driving `page` from their own controls.
- At the first / last page the Back / Next controls report aria-disabled and are guarded in the
  handler. Never the native disabled attribute: the user may be standing on that button and the
  browser would drop focus to <body>.
- Cleanup: the component owns no timers, no rAF loop and no observers by design; on unmount release
  any pointer capture still held and drop the drag record, so a gesture can never outlive the element
  that was driving it.

Rendering & styling
- Semantic tokens only: bg-card, bg-muted, bg-background, text-foreground, text-muted-foreground,
  border, ring, and bg-foreground/text-background for the one highest-priority control (Next) —
  which inverts instead of taking a colour. No colour literals of any kind, in any notation.
- Monochrome mobile scale: rail text 11–13px, page copy 12–13px, numbers tabular-nums with tight
  tracking. Cards rounded-2xl, inner blocks rounded-lg, tags rounded.
- The rail sits on the bottom edge of the screen, so it pads itself with
  calc(0.5rem + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))) — the custom
  property first so device-frame previews can simulate the inset.
- prefers-reduced-motion (subscribed through useSyncExternalStore, not read once) drops the settle
  transition and the dot morph to 0ms. The drag itself still tracks 1:1: the feature works with the
  decoration off.
- Every hit area is ≥44px. Nothing depends on hover; hover only deepens a dot that is already visible.

Customization levers
- Physics: FLING_VELOCITY (0.4px/ms) for how flick-happy it is, COMMIT_RATIO (0.28) for how far a
  slow drag has to go, EDGE_LIMIT (0.16 page) for how far the ends give, SETTLE_MS (320) and the
  easing for the page-turn feel. Raising COMMIT_RATIO past ~0.5 makes slow drags feel sticky.
- Rail: pick per screen — dots for ≤7 peer pages, bar for a finite flow, counter for many, none when
  you own the controls. Adding a rail means adding a button, not a shape.
- Layout: pages stretch to the tallest, so give the root a height (h-dvh for a real screen) to make
  them all fill it; pad inside each page rather than on the deck, so the drag reaches the bleed.
- Copy: label, previousLabel, nextLabel and each page's label are the i18n surface — the announcement
  and the dot names are built from them.
- Escape hatch: mark any control whose own gesture is horizontal with data-pager-no-swipe. Note that
  a nested horizontally *scrollable* region cannot work inside a pan-y viewport; give it its own page
  instead.
- Deliberately absent: looping. "There is nothing after this" is what the edge resistance exists to
  say, and a loop erases it — reach for a carousel instead.

Concepts

  • Paged viewport — the deck is not a scroller: one page is exactly one viewport wide, position is a single float in pages, and the resting state is always an integer. That is why the transform can be a percentage and survive any resize without measuring anything.
  • Edge resistance — past the first and last page the movement is damped asymptotically instead of clamped flat. The end of the deck becomes something you feel through the finger, which is the whole reason this belongs on a touchscreen rather than in a tab strip.
  • Velocity or distance commit — two independent ways to turn a page: throw it (direction wins, distance irrelevant) or push it past 28% (distance wins, speed irrelevant). Anything else springs home, so no gesture is ever accidentally decisive.
  • Axis lock and handoff — the first 8px of movement decides who owns the gesture, once and for all. Losing the axis means the pager withdraws completely, so a diagonal flick down a scrolling page never turns into a half-turned page.
  • Inert off-screen pages — invisible pages keep their DOM and their scroll positions but leave the tab order, and the focus they were holding is handed to the viewport rather than dropped on <body>.
  • Rail as readout, not decoration — dots, bar and counter are three presentations of the same position, and all three are made of real buttons, so the gesture never becomes the only way to move.

On This Page