Pager
A controlled single-DOM pager with touch gesture dragging, loop and View-Transition page switching, built for manga-style readers and paged content
Installation
Usage
Size contract
The component never adapts to its content — you must provide width and height via className (or style). The root clips with overflow-hidden, so an exact size prevents layout jitter entirely.
DOM strategy
Only the current page is mounted. During a touch drag the adjacent pages are mounted temporarily for the real-time transform follow, and they are unmounted as soon as the settle animation finishes. Programmatic jumps never mount more than one page.
Basic Usage
Fully controlled index / onChange — the demo drives it with navigation buttons and uses the default transition="view-transition". Browsers without document.startViewTransition fall back to an instant swap.
"use client";
import * as React from "react";
import { Pager } from "@/ui/components";
import { ChevronLeft, ChevronRight } from "lucide-react";
const pages = ["Alpha", "Bravo", "Charlie", "Delta"];
export default function PagerBasicDemo() {
const [index, setIndex] = React.useState(0);
return (
<div className="w-full space-y-3">
<Pager index={index} onChange={setIndex} className="h-48 w-full">
{pages.map((page) => (
<div
key={page}
className="flex h-full w-full items-center justify-center rounded-lg border bg-muted/40 text-2xl font-semibold"
>
{page}
</div>
))}
</Pager>
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setIndex((i) => Math.max(0, i - 1))}
disabled={index === 0}
aria-label="Previous page"
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-md border bg-background text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
>
<ChevronLeft className="size-4" />
</button>
<span className="text-sm text-muted-foreground">
{index + 1} / {pages.length}
</span>
<button
type="button"
onClick={() => setIndex((i) => Math.min(pages.length - 1, i + 1))}
disabled={index === pages.length - 1}
aria-label="Next page"
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-md border bg-background text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
>
<ChevronRight className="size-4" />
</button>
</div>
</div>
);
}
Gesture + Loop
Touch drag with a 25%-of-width threshold: pass it and the page commits, otherwise it snaps back. Beyond the boundary (with loop off) the drag gets rubber-band resistance. With loop on, indices wrap around modulo the page count.
Touch drag horizontally — loop enabled, current page 1 / 5
"use client";import * as React from "react";import { Pager } from "@/ui/components";const pages = ["01", "02", "03", "04", "05"];export default function PagerGestureDemo() { const [index, setIndex] = React.useState(0); return ( <div className="w-full space-y-3"> <Pager index={index} onChange={setIndex} loop className="h-48 w-full"> {pages.map((page) => ( <div key={page} className="flex h-full w-full items-center justify-center rounded-lg border bg-muted/40 text-4xl font-bold tabular-nums" > {page} </div> ))} </Pager> <p className="text-center text-sm text-muted-foreground"> Touch drag horizontally — loop enabled, current page {index + 1} / {pages.length} </p> </div> );}Behavior notes
- Programmatic jumps with
transition="view-transition"run insidedocument.startViewTransition; a new jump while a transition is still running skips the animation and swaps directly, so concurrent transitions never start. - Each instance generates a unique
view-transition-nameviauseId, so multiple Pager instances can coexist without snapshot conflicts. - The gesture path never uses View-Transition — it is pure
transformdisplacement while dragging plus a 200ms settle animation. - Use
transition="none"to disable programmatic animations entirely;gesture={false}disables touch handling. onChangealways receives the normalized index — wrapped modulo whenloopis on, clamped otherwise.
API Reference
Pager
| Prop | Type | Default | Description |
|---|---|---|---|
index | number | - | Controlled current page index |
onChange | (nextIndex: number) => void | - | Fired after a gesture commit; the parent updates index |
children | React.ReactNode[] | - | One child per page; only the active page is mounted |
loop | boolean | false | Wrap around at both boundaries |
transition | "none" | "view-transition" | "view-transition" | Animation strategy for programmatic jumps (gesture drags are unaffected) |
gesture | boolean | true | Enable touch drag paging |
className | ClassNameValue | - | Custom class names — must include the width and height |
...props | React.ComponentProps<"div"> | - | Native div props, spread on the root container |