Navigation
Pagination
Page-number navigation with a sliding sibling window, edge pinning, and an ellipsis only when the page count actually needs one.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/pagination.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Pagination" component (deps: lucide-react
for the Prev/Next/ellipsis icons).
Contract
- Fully controlled: page (1-based number), totalPages (number),
onPageChange: (page: number) => void — the component holds no internal page
state.
- siblings?: number = 1 — how many page numbers to show on each side of the
current page.
- showEdges?: boolean = true — always keep page 1 and totalPages visible even
when they fall outside the sibling window.
- className merges via cn(); spread the rest of the native <nav> props.
Behavior
- Windowed-ellipsis algorithm: clamp a window to
[page - siblings, page + siblings]. If totalPages fits inside
2*siblings + 5 slots, render every page 1..totalPages and skip ellipsis
entirely. Otherwise decide per side whether the window already touches the
edge (leftSibling <= 2 / rightSibling >= totalPages - 1) — pin that side's
real page range instead of an ellipsis, and only insert an ellipsis on the
side that's genuinely truncated. This keeps the rendered slot count
constant at 2*siblings + 5 in truncated mode, so the control never reflows
width as the user pages through.
- The ellipsis is a plain aria-hidden span (not a button) — it is never
focusable or clickable, it only communicates "more pages exist here."
- Prev/Next call onPageChange(page - 1) / onPageChange(page + 1); both are
real disabled <button>s at page 1 / totalPages (no dead click handlers).
- Every rendered control is a real <button type="button"> wired to
onPageChange — no href, no fake affordance.
Rendering & styling
- <nav aria-label="Pagination"> wraps everything; each page button has
aria-label="Page N"; the active one gets aria-current="page" plus
bg-primary text-primary-foreground, others get hover:bg-muted.
- Buttons are size-9 rounded-md with tabular-nums so switching between
1-digit and 2-digit pages never jitters the layout.
- Prev/Next render ChevronLeft/ChevronRight plus an sr-only label
("Previous page" / "Next page"); disabled state is disabled:opacity-40
disabled:pointer-events-none.
- Semantic tokens only (bg-primary / text-primary-foreground / bg-muted /
text-muted-foreground / ring); merge consumer className with cn();
focus-visible ring on every interactive control.
Customization levers
- URL-driven mode: swap the page <button onClick> for a Link (asChild-style)
pointing at a page-encoding href, and drop onPageChange in favor of route
navigation — the windowing algorithm and markup stay identical.
- Page-size selector: compose a <Select> for "items per page" next to this
component; Pagination only owns the page index, page size lives in the
parent's state and feeds totalPages.
- Compact variant: pass siblings={0} showEdges={false} to collapse the
control to Prev + current-page label + Next for narrow toolbars.
- Density: swap the size-9 buttons for size-8/size-10 and gap-1 for
gap-0.5/gap-2 to match a denser or looser layout.Concepts
- Sibling window —
siblingssets how many page numbers sit on each side of the current page; that window is what slides as the user pages through the set. - Constant slot count — in truncated mode the rendered item count (numbers + ellipses) is always
2×siblings + 5, so the control never grows, shrinks, or reflows width as the current page changes. - Edge pinning —
showEdgeskeeps page 1 and the last page visible even when they fall outside the sibling window, so there's always a one-click way back to either end. - Ellipsis as a label, not a control — the gap marker is an
aria-hidden, non-interactive span; it signals "more pages exist" without offering a button that goes nowhere. - Controlled navigation — the component owns no page state itself; every click reports through
onPageChange, so the parent stays the single source of truth and can drive local state, a URL, or a fetch.