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…

"use client"

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

const ELLIPSIS = "ellipsis" as const

type PageItem = number | typeof ELLIPSIS

/**
 * Windowed ellipsis: keep `siblings` pages either side of the current one, and near
 * the ends slide the window inwards to make up the difference, so the button count
 * stays a constant `2*siblings + 5` (first + ellipsis + window + ellipsis + last).

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/pagination.json

Prompt

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 windowsiblings sets 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 pinningshowEdges keeps 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.

On This Page