Progress Steps
An ordered list in a nav, not a row of divs — so the count and the position come free, and aria-current names the step you are on. Horizontal and vertical, because four labels do not fit on a phone.
A page window that keeps its width while you page and never draws an ellipsis for a single page — as real links when you pass getHref, so a listing stays crawlable and works without JavaScript.
components/ui/pagination.tsxnpx hoverlab add pagination
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.
One file. Create components/ui/pagination.tsx and paste.
'use client'
/**
* <Pagination> — previous, a window of page numbers, next.
*
* - `pageWindow()` is the part worth stealing. It always keeps the first and
* last page, keeps `siblings` pages either side of the current one, and
* collapses the rest to an ellipsis — except that a gap of exactly ONE page
* is drawn as that page, never as "…", because an ellipsis standing in for
* a single number is longer to read than the number. The width is also
* constant while you page, so the control does not jitter under the cursor.
* - Two modes from one component. Give it `getHref` and every page is a real
* `<a>`: crawlable, middle-clickable, and it works with JavaScript off,
* which is what a listing that wants to be indexed needs. Omit it and they
* are buttons calling `onPageChange`.
* - Numbered links are named "Page 3" rather than just "3", and the current
* one is `aria-current="page"`. A polite live line says "Page 3 of 20" when
* the page changes, because the content swapping is otherwise silent.
* - The first/last "disabled" states are `aria-disabled` on links (a disabled
* link does not exist) and `disabled` on buttons.
*/
import * as React from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
export type PageItem = number | 'gap-start' | 'gap-end'
/**
* The visible page numbers for `current` of `total`.
* `siblings` is how many neighbours to keep on each side of the current page.
*/
export function pageWindow(current: number, total: number, siblings = 1): PageItem[] {
const count = Math.max(0, Math.floor(total))
if (count === 0) return []
const page = Math.min(Math.max(1, Math.floor(current)), count)
const s = Math.max(0, Math.floor(siblings))
// first + last + current + a sibling run each side + two gaps.
const width = 2 * s + 5
if (count <= width) return Array.from({ length: count }, (_, i) => i + 1)
/*
* Every window is exactly `width` cells, wherever the current page is.
* That is what stops the control changing size under the cursor as you
* click through it, and it is why the two ends are not just the middle case
* with the gap left out: near an edge the run of numbers grows to fill the
* cells the missing gap would have used.
*
* The edges start where the left gap would hide fewer than TWO pages (page
* `s + 3`), because an ellipsis for one page is longer than the page.
*/
const run = (from: number, to: number): PageItem[] =>
Array.from({ length: to - from + 1 }, (_, i) => from + i)
if (page <= s + 3) return [...run(1, 2 * s + 3), 'gap-end', count]
if (page >= count - s - 2) return [1, 'gap-start', ...run(count - 2 * s - 2, count)]
return [1, 'gap-start', ...run(page - s, page + s), 'gap-end', count]
}
export interface PaginationProps {
page: number
pageCount: number
onPageChange?: (page: number) => void
/** Build a URL for a page to render links instead of buttons. */
getHref?: (page: number) => string
siblings?: number
/**
* Draw the words "Previous" and "Next" beside the arrows. Off by default:
* with a full window of numbers they do not fit a narrow column, and a pager
* that wraps its Next button onto a line of its own is worse than one with
* two arrows. Their accessible names ("Previous page", "Next page") are the
* same either way. On a phone, `siblings={0}` also saves two cells.
*/
labels?: boolean
label?: string
className?: string
}
export function Pagination({
page,
pageCount,
onPageChange,
getHref,
siblings = 1,
labels = false,
label = 'Pagination',
className = '',
}: PaginationProps) {
if (pageCount < 1) return null
const current = Math.min(Math.max(1, page), pageCount)
const items = pageWindow(current, pageCount, siblings)
const base =
'inline-flex h-9 min-w-9 items-center justify-center gap-1 rounded-lg px-2.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
const idle = 'text-muted-foreground hover:bg-muted hover:text-foreground'
const control = (
target: number,
content: React.ReactNode,
opts: { name: string; active?: boolean; disabled?: boolean },
) => {
const cls = `${base} ${opts.active ? 'bg-primary text-primary-foreground' : opts.disabled ? 'pointer-events-none opacity-40' : idle}`
if (getHref) {
return (
<a
href={opts.disabled ? undefined : getHref(target)}
aria-label={opts.name}
aria-current={opts.active ? 'page' : undefined}
aria-disabled={opts.disabled || undefined}
tabIndex={opts.disabled ? -1 : undefined}
className={cls}
onClick={(e) => {
if (opts.disabled) return e.preventDefault()
if (onPageChange) {
e.preventDefault()
onPageChange(target)
}
}}
>
{content}
</a>
)
}
return (
<button
type="button"
aria-label={opts.name}
aria-current={opts.active ? 'page' : undefined}
disabled={opts.disabled}
onClick={() => onPageChange?.(target)}
className={cls}
>
{content}
</button>
)
}
return (
<nav aria-label={label} className={`relative ${className}`}>
<ul className="flex flex-wrap items-center gap-1">
<li>
{control(
current - 1,
<>
<ChevronLeft aria-hidden className="h-4 w-4 rtl:rotate-180" />
{labels ? <span>Previous</span> : null}
</>,
{ name: 'Previous page', disabled: current <= 1 },
)}
</li>
{items.map((item) =>
typeof item === 'number' ? (
<li key={item}>
{control(item, item, { name: `Page ${item}`, active: item === current })}
</li>
) : (
<li key={item} aria-hidden className="px-1 text-muted-foreground">
…
</li>
),
)}
<li>
{control(
current + 1,
<>
{labels ? <span>Next</span> : null}
<ChevronRight aria-hidden className="h-4 w-4 rtl:rotate-180" />
</>,
{ name: 'Next page', disabled: current >= pageCount },
)}
</li>
</ul>
<p role="status" className="sr-only">
Page {current} of {pageCount}
</p>
</nav>
)
}
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
pagerequired | number | — |
pageCountrequired | number | — |
onPageChange | (page: number) => void | — |
getHrefBuild a URL for a page to render links instead of buttons. | (page: number) => string | — |
siblings | number | 1 |
labelsDraw the words "Previous" and "Next" beside the arrows. Off by default: with a full window of numbers they do not fit a narrow column, and a pager that wraps its Next button onto a line of its own is worse than one with two arrows. Their accessible names ("Previous page", "Next page") are the same either way. On a phone, `siblings={0}` also saves two cells. | boolean | false |
label | string | 'Pagination' |
className | string | '' |
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
An ordered list in a nav, not a row of divs — so the count and the position come free, and aria-current names the step you are on. Horizontal and vertical, because four labels do not fit on a phone.
One tab stop for the whole tree, with the ARIA tree keyboard contract in full — including typeahead and the arrow keys mapped through the reading direction.
One tab stop for the whole list, arrow keys mapped through the reading direction, and a manual-activation mode so arrowing past five expensive panels does not fire four requests nobody asked for.