usePagination
Headless pagination state — clamped page, page count, item range, a client-side slice() and the ellipsis page sequence, controlled or uncontrolled.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-pagination.jsonPrompt
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:
boundaryCountpages pinned at each end (so page 1 and the last page are always reachable) and a window ofsiblingCountpages 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 5weirdness 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-effectviolation, and growingtotalagain won't teleport you back to the stale page. - Auto-clamp is a derivation, not a navigation —
onPageChangefires 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 returnedpage, which is always in range. total = 0still has one page —pageCountbottoms out at 1 sopagestays a valid 1 and the empty state needs no special branch;rangecollapses to0–0and every control reportsdisabled.rangefor the server,slice()for the client — the same state feeds both models:slice()windows an array you already hold in memory (it is a purepage/pageSizewindow and trusts you that the array is the datasettotaldescribes), whilerange/page/pageSizeare what you put in the request when the backend does the paging.