Hooks

usePagination

Headless pagination state — clamped page, page count, item range, a client-side slice() and the ellipsis page sequence, controlled or uncontrolled.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

const START_ELLIPSIS = "start-ellipsis" as const
const END_ELLIPSIS = "end-ellipsis" as const

type SequenceEntry = number | typeof START_ELLIPSIS | typeof END_ELLIPSIS

export type PaginationItemType = "first" | "prev" | "page" | "ellipsis" | "next" | "last"

export interface PaginationItem {
  /** Control type. `"page"` is a navigable page number, `"ellipsis"` is display-only. */
  type: PaginationItemType

Installation

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

Prompt

Build a React + TypeScript "usePagination" hook (React only, no other
dependencies). It renders nothing — it returns pagination state plus a
render-ready control sequence.

Contract
- `usePagination({ total, pageSize = 10, page, defaultPage = 1,
  siblingCount = 1, boundaryCount = 1, onPageChange }): { page, pageCount,
  pageSize, setPage, next, prev, first, last, canNext, canPrev,
  range: { start, end }, slice, items }`.
- `total` is the number of ROWS, not pages. `pageSize` is clamped to >= 1 and
  `total` to >= 0 (both truncated to integers, non-finite values fall back to
  the minimum), so `pageCount = Math.max(1, Math.ceil(total / pageSize))` is
  always a finite positive integer — `pageSize = 0` must never produce
  `Infinity` pages or an unbounded loop.
- Passing `page` switches the hook to controlled mode (the caller owns the
  number). Without it the hook keeps internal state seeded once from
  `defaultPage`, exactly like `useState`.
- `page` in the result is always clamped into `[1, pageCount]`, so it is safe
  to render directly without re-checking bounds.
- `pageSize` is returned back (post-clamp) so callers render the value that was
  actually used, not the one they passed.
- `range` is a 1-based inclusive item range for "Showing {start}–{end} of
  {total}" labels; with `total = 0` both ends are 0.
- `slice<T>(items: readonly T[]): T[]` returns the current page's window of a
  fully client-side array (`items.slice((page - 1) * pageSize, page *
  pageSize)`). Server-side pagination ignores `slice` and sends `range` (or
  `page`/`pageSize`) to the API instead.
- `items: Array<{ type: "first" | "prev" | "page" | "ellipsis" | "next" |
  "last"; page?: number; selected: boolean; disabled: boolean; key: string }>`,
  in order: first · prev · pages/ellipsis · next · last. `page` is the already
  clamped target the control should navigate to (so `onClick={() =>
  setPage(item.page)}` is always safe) and is undefined only for `"ellipsis"`.
  `selected` is true only for the current page; `disabled` is true for the
  ellipsis and for first/prev at page 1 and next/last at the last page.
  `key` is unique inside the sequence ("page-7", "ellipsis-start", "prev", …).
  Consumers that do not want jump-to-first/last buttons filter those two types
  out rather than passing a flag.

Behavior
- Page sequence: always show `boundaryCount` pages at each end, plus a window
  of `siblingCount` pages either side of the current page. When the window
  approaches an edge it slides inward so the number of visible controls stays
  constant and the buttons do not jump around under the cursor.
- An ellipsis is only emitted when it would hide at least TWO pages. If a gap
  is exactly one page wide, render that page instead — same width, and it is
  clickable. When the total page count fits in the available slots the sequence
  degrades to a plain `1 2 3 4 5` with no ellipsis at all.
- `siblingCount` and `boundaryCount` are clamped to >= 0 integers. Even with
  absurd inputs (`boundaryCount` larger than `pageCount`) the sequence must
  stay strictly ascending with no duplicated page numbers — build it from a
  `range(start, end)` helper that returns `[]` when `end < start` instead of
  special-casing every edge.
- Out-of-range pages are clamped, never thrown. When `total` shrinks (rows
  deleted, filter applied) or `pageSize` grows so that the current page no
  longer exists, the hook falls back to the new last page. In uncontrolled
  mode that clamped value is written back to internal state DURING RENDER
  (the adjust-state-during-render pattern: `if (page !== statePage)
  setStatePage(page)`), never with a `setState` inside an effect — an effect
  would commit one extra frame showing an empty page, and would trip
  `react-hooks/set-state-in-effect`. Writing it back also means a later growth
  of `total` does not teleport the user to the stale page number.
- Auto-clamping does NOT call `onPageChange`: it is a derivation, not a
  navigation. `onPageChange` fires only from `setPage`/`next`/`prev`/`first`/
  `last`, and only when the target differs from the current page (clicking the
  page you are already on is a no-op). Document that in controlled mode the
  caller's own state is never rewritten by the hook — render from the returned
  `page`.
- `onPageChange` is read through a ref refreshed on every render (latest-ref
  pattern) so a consumer passing an inline arrow does not invalidate anything.
- `total = 0` keeps `pageCount = 1` (one empty page) rather than 0, so `page`
  stays a valid 1, the sequence still renders a single disabled-looking page
  and no consumer needs an extra "no pages" branch. `range` collapses to
  `{ start: 0, end: 0 }` (so the "showing X–Y" label reads 0–0 instead of
  1–10 of nothing), and slicing the empty array naturally yields `[]`.
- `next`/`prev`/`first`/`last` are thin wrappers over the clamping `setPage`;
  `canPrev`/`canNext` are `page > 1` / `page < pageCount`.
- The controls close over the current page and page count, so their function
  identity changes when those change. They are event handlers — do not put
  them in effect dependency arrays.

Rendering & styling
- The hook renders no DOM. Consumer UI (from `items`): a
  `<nav aria-label="Pagination">`, `aria-current="page"` on the selected page,
  `aria-label` on the icon-only controls, `aria-hidden` on the ellipsis (it is
  decoration, not a target), `type="button"` everywhere.
- Semantic tokens only: `bg-primary text-primary-foreground` for the current
  page, `border text-foreground hover:bg-muted` for the others,
  `text-muted-foreground` for the ellipsis and the range label,
  `focus-visible:ring-2 focus-visible:ring-ring`,
  `disabled:opacity-40 disabled:pointer-events-none`.
- Put `tabular-nums` on every number (page buttons, the range label) so widths
  do not twitch when digits change, and give the cells a fixed height with
  `min-w` rather than a fixed width so page 100 still fits.
- There is nothing to animate; if a page transition is animated, keep it a
  fade/opacity and gate it behind `motion-reduce:transition-none`.

Customization levers
- `pageSize` — drive it from a "rows per page" control; the hook re-derives
  `pageCount` and clamps the current page for you.
- `siblingCount` / `boundaryCount` — the width dials. `siblingCount: 0` +
  `boundaryCount: 1` gives the tightest mobile bar (`1 … 7 … 20`); bump both
  for a wide desktop footer. Total visible page numbers is at most
  `2 * boundaryCount + 2 * siblingCount + 3`.
- Controlled vs uncontrolled — pass `page` + `onPageChange` to sync with a
  `?page=` query param, a URL segment or a parent store; omit both for a local
  table footer.
- Which controls to render — filter `items` by `type` to drop the
  first/last jump buttons, or render only `type === "page"` items and use
  separate `prev()`/`next()` buttons of your own.
- Client vs server paging — `slice()` for an array you already hold, `range` /
  `page` / `pageSize` to build the request when the server pages for you.
- Labels — `range.start`/`range.end`/`total` are raw numbers on purpose;
  format ("Showing 11–20 of 42", "Page 2 of 5") at the call site, and add
  `Intl.NumberFormat("en-US")` with an explicit locale if you need grouping.

Concepts

  • Sibling window + boundary pages — the sequence is built from two ideas: boundaryCount pages pinned at each end (so page 1 and the last page are always reachable) and a window of siblingCount pages on either side of the current page. Near an edge the window slides inward instead of shrinking, which is what keeps the bar a constant width while you click through.
  • An ellipsis must earn its slot — dots are only emitted when they hide at least two pages; a one-page gap is rendered as that page number instead. Same footprint, one more clickable target, and no 1 … 3 4 5 weirdness where the dots stand for a single page.
  • Clamp on shrink, written back during render — deleting rows or applying a filter can make the current page disappear. The hook clamps to the new last page and, when uncontrolled, writes that value back during render (adjust-state-during-render) rather than from an effect: no extra committed frame showing an empty page, no react-hooks/set-state-in-effect violation, and growing total again won't teleport you back to the stale page.
  • Auto-clamp is a derivation, not a navigationonPageChange fires only for real navigation (and only when the target differs from the current page), never for an internal clamp. In controlled mode the hook never rewrites the caller's state, so render from the returned page, which is always in range.
  • total = 0 still has one pagepageCount bottoms out at 1 so page stays a valid 1 and the empty state needs no special branch; range collapses to 0–0 and every control reports disabled.
  • range for the server, slice() for the client — the same state feeds both models: slice() windows an array you already hold in memory (it is a pure page/pageSize window and trusts you that the array is the dataset total describes), while range/page/pageSize are what you put in the request when the backend does the paging.

On This Page