Pagination
A pager for a table, a card grid, or any list: numbered pages, prev and next, a row count, and an optional rows-per-page select.
Installation
Usage
import { Pagination } from "@/components/ui/pagination"Examples
Default
Page numbers with the current one filled. Pages either side of it stay visible and the rest collapse to an ellipsis, so the row is the same length on page 2 as on page 40 and nothing beside it shifts as you move.
Under a table
The count and the rows-per-page control take the table's left edge, the pager takes its right. The select needs no visible label: it reads "10 per page" on its own. Changing the size sends you back to page 1, since page 9 of 47 rows stops existing at 25 per page.
| Order | Customer | Total |
|---|---|---|
| ORD-4820 | Alex Rivera | $180 |
| ORD-4821 | Dana Cole | $217 |
| ORD-4822 | Sam Okafor | $254 |
| ORD-4823 | Priya Nair | $291 |
| ORD-4824 | Alex Rivera | $328 |
Under anything else
Nothing about this is table-shaped. With no count and no page size beside it the pager is the whole row, so it centers under the grid it belongs to.
Paging by URL
Return the element each page should render as and the pager draws links instead of buttons, which is what a Server Component needs: it cannot hand a click handler to a button, but it can build a link. The two bounds stay buttons, since a link has no disabled state to give them.
Siblings
How many pages stay visible either side of the current one: 0, 1, and 2. Every value keeps its own row length fixed, so this is a density choice, not a behavior one.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| page* | number | - | The current page, 1-based. |
| pageCount* | number | - | How many pages there are in total. |
| onPageChange | (page: number) => void | - | Called with the page that was clicked. Omit it for link paging (renderPage); the pager only builds a click handler when it was given one. |
| siblings | number | 1 | How many pages to show either side of the current one. The first and last page are always shown, so the row holds siblings * 2 + 5 controls at every page. |
| total | number | - | Total number of rows. With pageSize, renders the "1-10 of 240" count. A total of 0 reads "No results". |
| pageSize | number | - | Rows per page. Needed for the count, and it is the current value of the size select. |
| pageSizeOptions | number[] | - | Offering these renders the rows-per-page select. Needs onPageSizeChange. |
| onPageSizeChange | (pageSize: number) => void | - | Called with the newly chosen page size. Send the user back to page 1 here. |
| renderPage | (page: number) => ReactElement | - | For URL paging: return the element a page control should render as, e.g. (page) => <Link href={?page=${page}} />. It takes the button's styling and its label. |
import * as React from "react"
import { IconChevronLeft as ChevronLeft, IconChevronRight as ChevronRight, IconDots as MoreHorizontal } from "@tabler/icons-react"
import { cn } from "@/registry/lib/utils"
import { Button } from "@/registry/ui/button"
import { buttonVariants } from "@/registry/ui/button-variants"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/registry/ui/select"
type PageItem = number | "ellipsis-start" | "ellipsis-end"
// One height across the row, and the height is 32: the pager sits under a table
// whose toolbar is already a single-height row, and a control ladder that
// changes rung between the top and the bottom of the same table reads as two
// different tables. That is also why there is no `size` prop. The square rungs
// run 24 / 32 / 40, so a 28px pager would have no matching square for its
// chevrons and the row would come out ragged.
const ITEM_CLASS = "w-auto min-w-8 px-2 tabular-nums"
// Every window is the same LENGTH, which is the point of the branching below.
// The naive version drops the ellipsis when the current page is near an end and
// renders fewer controls there, so the row changes width as you page through it
// and everything beside it shifts. Widening the window to fill the freed slots
// keeps the count fixed at `siblings * 2 + 5` (first, last, two ellipses, and
// the current page with its siblings).
function pageItems(page: number, pageCount: number, siblings: number): PageItem[] {
const slots = siblings * 2 + 5
const range = (from: number, to: number) =>
Array.from({ length: Math.max(0, to - from + 1) }, (_, i) => from + i)
if (pageCount <= slots) return range(1, pageCount)
const leftGap = page - siblings > 2
const rightGap = page + siblings < pageCount - 1
const runLength = siblings * 2 + 3
if (!leftGap && rightGap) return [...range(1, runLength), "ellipsis-end", pageCount]
if (leftGap && !rightGap) {
return [1, "ellipsis-start", ...range(pageCount - runLength + 1, pageCount)]
}
return [
1,
"ellipsis-start",
...range(page - siblings, page + siblings),
"ellipsis-end",
pageCount,
]
}
type PaginationProps = Omit<React.ComponentProps<"nav">, "onChange"> & {
/** The current page, 1-based. */
page: number
/** How many pages there are in total. */
pageCount: number
/** Called with the page that was clicked. Omit for link paging (`renderPage`). */
onPageChange?: (page: number) => void
/** How many pages to show either side of the current one. */
siblings?: number
/** Total number of rows. With `pageSize`, renders the "1-10 of 240" count. */
total?: number
/** Rows per page. Needed for the count, and the current value of the size select. */
pageSize?: number
/** Offering these renders the rows-per-page select. Needs `onPageSizeChange`. */
pageSizeOptions?: number[]
onPageSizeChange?: (pageSize: number) => void
/**
* For URL paging: return the element a page control should render as, e.g.
* `(page) => <Link href={`?page=${page}`} />`. It takes the button's styling
* and its label. Controls that are disabled at the bounds stay buttons, since
* a link has no disabled state to give them.
*/
renderPage?: (page: number) => React.ReactElement
}
// @use-when paging through a list by URL.
function Pagination({
page,
pageCount,
onPageChange,
siblings = 1,
total,
pageSize,
pageSizeOptions,
onPageSizeChange,
renderPage,
className,
style,
...props
}: PaginationProps) {
const items = pageItems(page, pageCount, siblings)
const showCount = total !== undefined && pageSize !== undefined
const showSize = pageSizeOptions !== undefined && pageSizeOptions.length > 0
const hasMeta = showCount || showSize
const control = (
target: number,
content: React.ReactNode,
{
current = false,
disabled = false,
label,
}: { current?: boolean; disabled?: boolean; label?: string } = {}
) => {
const variant = current ? "soft" : "ghost"
// A link cannot be disabled, so a bound that has nowhere to go stays a
// button. Same for the click path, which has no element to render.
if (renderPage && !disabled) {
const element = renderPage(target)
return React.cloneElement(
element as React.ReactElement<React.ComponentProps<"a">>,
{
className: cn(
buttonVariants({ variant, size: "icon" }),
ITEM_CLASS,
(element.props as { className?: string }).className
),
"aria-label": label,
"aria-current": current ? "page" : undefined,
},
content
)
}
return (
<Button
variant={variant}
size="icon"
className={ITEM_CLASS}
aria-label={label}
aria-current={current ? "page" : undefined}
disabled={disabled}
// Only built when there is a handler to build it from. A Server
// Component may render this component, and a function created there and
// handed to a client Button fails the render outright.
onClick={onPageChange && (() => onPageChange(target))}
>
{content}
</Button>
)
}
return (
<nav
data-slot="pagination"
aria-label="Pagination"
className={cn(
"flex flex-wrap items-center",
// With nothing on the left the pager is the whole row, which is the
// shape a card grid or a list wants: centered under its content. A
// table has a count and a page size beside it, so the two clusters take
// the table's own two edges.
hasMeta ? "justify-between" : "justify-center",
className
)}
style={{ gap: "var(--spacing-rhythm-cluster)", ...style }}
{...props}
>
{hasMeta && (
<div
data-slot="pagination-meta"
className="flex flex-wrap items-center"
style={{ gap: "var(--spacing-rhythm-cluster)" }}
>
{showSize && (
<Select
value={pageSize}
onValueChange={(value) => onPageSizeChange?.(value as number)}
>
{/* No visible label, and the value says it instead: a trigger
reading "10 per page" needs no word in front of it, while a
label would put a second line of text under the table for a
control most people never touch. Same rule the table toolbar
runs on. */}
<SelectTrigger aria-label="Rows per page" data-slot="pagination-size">
<SelectValue />
</SelectTrigger>
{/* align="start" is load-bearing, not a tidy-up target.
SelectContent's popup is w-(--anchor-width) with a min-w-32
floor, and this trigger reads "10 per page", which is under
128px. So the floor wins, the popup comes out wider than the
trigger, and the default align="center" hangs the difference
off BOTH sides. This select sits at the left edge of the
pager row, so the left overhang breaks the edge the count and
the table above it line up on. */}
<SelectContent align="start">
{pageSizeOptions.map((option) => (
<SelectItem key={option} value={option}>
{option} per page
</SelectItem>
))}
</SelectContent>
</Select>
)}
{showCount && (
<span
data-slot="pagination-count"
// Tabular figures, because this string changes on every page turn
// and proportional digits make the whole line breathe in and out.
className="text-muted-foreground text-1xs tabular-nums"
>
{total === 0
? "No results"
: `${(page - 1) * pageSize + 1}-${Math.min(page * pageSize, total)} of ${total}`}
</span>
)}
</div>
)}
<ul data-slot="pagination-list" className="flex items-center gap-1">
<li>
{control(
page - 1,
// rtl:rotate-180, because "previous" is a direction, not a glyph:
// the arrow has to point at the edge the earlier pages are on.
<ChevronLeft className="rtl:rotate-180" />,
{ disabled: page <= 1, label: "Go to previous page" }
)}
</li>
{items.map((item) =>
typeof item === "number" ? (
<li key={item}>
{control(item, item, {
current: item === page,
label: `Go to page ${item}`,
})}
</li>
) : (
<li
key={item}
data-slot="pagination-ellipsis"
// Decorative. The pages it stands for are still reachable one
// step at a time, and announcing "more pages" between every
// number would make the row read as twice its length.
aria-hidden="true"
className="text-muted-foreground flex size-8 items-center justify-center [&>svg]:size-3"
>
<MoreHorizontal />
</li>
)
)}
<li>
{control(page + 1, <ChevronRight className="rtl:rotate-180" />, {
disabled: page >= pageCount,
label: "Go to next page",
})}
</li>
</ul>
</nav>
)
}
// THE PAGER SHIPS NO TOP MARGIN, the same way the toolbar ships no bottom one.
// A child never sets a margin to make room for a sibling: wrap the table and
// its pager in a `<Stack gap="cluster">` and let the container own the space.
//
// NO "use client" IN THIS FILE, ON PURPOSE. It is what lets a Server Component
// page render URL paging: `renderPage` is called during that render rather than
// serialized across a boundary, which is exactly what a function prop cannot
// survive. Adding a directive here (a hook, `useRender`, any state) would take
// link paging away from every server page in one edit, and nothing would fail
// until someone deployed one.
export { Pagination }
export type { PaginationProps }