Pagination
Navigate between pages of content. A numbered row with ellipsis truncation, or a compact "Page N of M" counter that takes over on narrow screens. Five sizes, three active-page variants, six radii, first/last buttons, link mode, and a toolbar set — range label, page-size select and jump-to-page.
Playground
Installation
pnpm add @tessinaui/uiUsage
import { Pagination } from "@tessinaui/ui";<Pagination count={10} defaultPage={1} />Controlled, when the page lives in your own state:
<Pagination count={10} page={page} onChange={setPage} />From a record count instead of a page count:
<Pagination totalItems={128} pageSize={20} />With jumps straight to the ends:
<Pagination count={50} defaultPage={25} showFirstButton showLastButton />As real links, for crawlable pagination:
<Pagination count={10} getHref={(page) => `/results?page=${page}`} />When to use it
Reach for Pagination when the reader needs to know where they are in a finite set, jump to a specific point, or link to one — search results, a data table, an archive.
Reach for something else when they are browsing rather than navigating.
Image grids, activity feeds and mobile timelines almost universally use
infinite scroll or a "Load more" button instead; of the media-grid and feed
products sampled for this component, not one paginated. A "Load more" button is
a Button, not a degenerate pager.
Examples
Default
A numbered row with chevron nav buttons and ellipsis truncation.
Responsive
The default layout="auto" shows the number row from sm up and a compact
counter below it. Narrow the preview to see the swap.
The reason is arithmetic: below md every item is floored to a 44px touch
target, so nine of them plus gaps need about 430px — more than a phone has.
Shrinking the items would break the touch target, so the count drops
instead. The swap is CSS-only, so it survives server rendering without a
hydration mismatch.
Simple
layout="simple" is the counter at every width — the most common shape in the
wild, and the one most mobile apps use. getStatusLabel reformats it.
Variants
Three active-page styles — default, primary, and outline.
Sizes
Five size tokens from xs to xl.
Rounded
Six radii. Every painted corner derives proportionally, none included.
Nav styles
Navigation buttons as chevron icon, text label, or none. The labels are
overridable for wording and for translation.
First and last
showFirstButton and showLastButton add double-chevron jumps outside
Previous and Next.
Truncation
siblingCount widens the window around the current page; boundaryCount pins
pages at each end. A marker is never used to hide a single page — that page is
printed instead. These use layout="numbers", so they stay on one line on a
phone by taking the compact density.
Controlled
Drive the active page from external state via page and onChange.
Total items
Give totalItems and pageSize instead of count and the page count is
derived, partial last page included.
Range label
PaginationRange renders "1–20 of 128 items". It is a separate export because
the three products that show it put it in three different places.
Page size
PaginationPageSizeSelect composes the house Select. Changing the size keeps
the first record you were looking at on screen rather than resetting to page 1.
Jump to page
PaginationJump commits on Enter or blur — never per keystroke, which would
fetch page 1 on the way to typing "12".
Toolbar
The three-zone table footer: range, pager, page size, all agreeing through one
PaginationRoot.
Links
getHref turns items into real anchors, so pages are crawlable and openable in
a new tab. Previous and Next carry rel="prev" / rel="next". Use
renderLink to route them through next/link.
Loading
loading marks an in-flight page change — the pager goes busy but every
control stays focusable. PaginationSkeleton is the different case: there is
nothing to page through yet.
On a colour plate
tone="on-color" (or an enclosing coloured Surface, which supplies it) draws
the pager in the plate's own ink. On a plate you paint yourself, also publish its
ink with surfaceInkClasses — the pager's text, its current-page chip and its
focus ring's gap all read the plate from there, and fall back to the page
without it.
RTL
dir="rtl" flips the chevrons and mirrors the control order, so the largest
jump stays outermost in the reading direction.
API Reference
Pagination
| Prop | Type | Default | Description |
|---|---|---|---|
count | number | — | Total number of pages. Optional inside a PaginationRoot, or when totalItems is given |
totalItems | number | — | Total record count; with pageSize, derives count |
pageSize | number | 10 | Records per page, used with totalItems |
page | number | — | Controlled current page (1-indexed) |
defaultPage | number | 1 | Uncontrolled initial page |
onChange | (page: number) => void | — | Called when the active page changes |
siblingCount | number | 1 | Pages shown on each side of the current page |
boundaryCount | number | 1 | Pages always shown at the start and end boundaries |
layout | "auto" | "numbers" | "simple" | "auto" | What renders between the nav buttons — see below |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Visual size token |
variant | "default" | "primary" | "outline" | "default" | Active page indicator style |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "full" | Border radius for all controls |
tone | "default" | "on-color" | inherited | Ink treatment; inherited from an enclosing Surface |
navStyle | "icon" | "label" | "none" | "icon" | Navigation button style — see below |
showFirstButton | boolean | false | Add a jump-to-first button outside Previous |
showLastButton | boolean | false | Add a jump-to-last button outside Next |
disabled | boolean | false | Disable all interactions (natively disabled) |
loading | boolean | false | A page change is in flight — busy, input refused, focus kept |
hideOnSinglePage | boolean | false | Render nothing when there is one page or fewer |
dir | "ltr" | "rtl" | "ltr" | Text direction — RTL flips chevrons and mirrors control order |
prevLabel | string | "Previous" | Label for the Previous button (navStyle="label") |
nextLabel | string | "Next" | Label for the Next button |
firstLabel | string | "Go to first page" | Accessible name for the first-page button |
lastLabel | string | "Go to last page" | Accessible name for the last-page button |
aria-label | string | "Pagination" | Name of the landmark; set it when a page has several pagers |
getPageAriaLabel | (page: number, selected: boolean) => string | — | Custom aria-label for page buttons |
getStatusLabel | (page: number, count: number) => string | Page N of M | Formats the counter and the announcement |
getHref | (page: number) => string | — | Render items as links to these URLs |
renderLink | (props) => ReactNode | — | Wrap link-mode items in a router link |
className | string | — | Additional class on the root <nav> |
PaginationRoot
Optional state owner. Wrap parts in it only when more than one has to agree.
| Prop | Type | Default | Description |
|---|---|---|---|
count / totalItems | number | — | Page count, or record count to derive it from |
page / defaultPage | number | 1 | Controlled / uncontrolled page |
onPageChange | (page: number) => void | — | Fired when any child moves the page |
pageSize / defaultPageSize | number | 10 | Controlled / uncontrolled records per page |
onPageSizeChange | (size: number) => void | — | Fired by the page-size select |
size, rounded, tone, disabled | — | — | Inherited by every part |
PaginationRange
| Prop | Type | Default | Description |
|---|---|---|---|
page, pageSize, totalItems | number | from Root | Override the Root's values |
noun | string | "items" | Noun for the records |
format | ({from, to, total}) => string | — | Full control of the string |
size, tone | — | from Root |
PaginationPageSizeSelect
| Prop | Type | Default | Description |
|---|---|---|---|
options | number[] | [10, 20, 50, 100] | Selectable page sizes |
value | number | from Root | Controlled value |
onValueChange | (size: number) => void | — | Falls back to the Root |
label | string | "items per page" | Visible text beside the select |
labelPlacement | "start" | "end" | "end" | Which side the label sits on |
size, rounded, tone, disabled | — | from Root |
PaginationJump
| Prop | Type | Default | Description |
|---|---|---|---|
page, count | number | from Root | Override the Root's values |
onChange | (page: number) => void | — | Falls back to the Root |
label | string | "Page" | Text before the field |
suffix | ReactNode | null | of {count} | Text after the field; null removes it |
size, rounded, tone, disabled | — | from Root |
PaginationSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
size | PaginationSize | "md" | Matches the real pager's box |
rounded | PaginationRounded | "full" | |
count | number | 5 | Number of placeholder page blocks |
navStyle | PaginationNavStyle | "icon" | Mirrors the real navStyle |
layout | PaginationLayout | "auto" | Mirrors the real layout |
showFirstButton / showLastButton | boolean | false | |
page, siblingCount, boundaryCount | number | 1 | Decide where the ellipses fall |
label | string | "Loading" | What the live region says while loading |
Layouts
| Value | Description |
|---|---|
"auto" | Counter below sm, number row from sm up (default) |
"numbers" | The number row at every width, always on one line |
"simple" | The "Page N of M" counter at every width |
numbers is the one layout that gives up the 44px touch target. Nine 44px cells
plus gaps need about 430px, so the row cannot both keep that target and stay on
one line on a phone. auto and simple resolve it by showing fewer cells and
keep the 44px; numbers keeps every cell and drops to the compact density
instead — still above the WCAG 2.5.8 (AA) 24px minimum, but below 2.5.5 (AAA).
That is the whole reason auto is the default.
Nav styles
| Value | Description |
|---|---|
"icon" | Chevron icon buttons only — [<] [pages] [>] (default) |
"label" | Previous / Next text buttons with icons |
"none" | No navigation buttons — page items only |
Variants
| Value | Active page style |
|---|---|
"default" | bg-secondary (neutral fill) |
"primary" | bg-primary text-on-primary (brand fill) |
"outline" | ring-2 ring-inset ring-primary text-primary (outlined) |
Accessibility
- Renders as
<nav aria-label="Pagination">. There is no redundantrole="navigation"—<nav>already carries it, and the old"pagination navigation"label made screen readers say "navigation" twice. Give each pager its ownaria-labelwhen a page has more than one. - The active page carries
aria-current="page". Every control has a descriptive name, overridable throughgetPageAriaLabel. - A page change is announced. A polite live region speaks "Page 4 of 12" and clears after two seconds. Without it, activating a page number moves focus nowhere and silently replaces content the screen-reader cursor is not on — the change is invisible to assistive technology.
- Boundary controls are
aria-disabled, never nativelydisabled. Previous on page 1 stays focusable and dimmed in place. A natively disabled button that switches off while focused drops focus to<body>, which is what the old implementation did on every trip back to page 1. Link mode forces the same answer independently: an<a>has no native disabled state. - Truncation markers are not silent. Each carries
sr-onlytext naming what it hides — "Pages 2 through 8 not shown" — so the jump from 1 to 9 is explained rather than mysterious. - Touch targets are floored at 44×44 below
md(WCAG 2.5.5) inautoandsimple. This is whyautoswaps to the counter on phones rather than shrinking items.layout="numbers"is the documented exception: it keeps every cell on one line at the compact density, so its targets meet the WCAG 2.5.8 (AA) 24px minimum but not the 44px enhanced target. Preferautounless you specifically need every page number visible on a phone. PaginationSkeletonis arole="status"region with real text, so it announces on insertion instead of being an empty application status.
Keyboard
| Key | Action |
|---|---|
Tab / Shift+Tab | Move through the controls in visual order |
Enter / Space | Activate the focused control |
Enter | In PaginationJump, commit the typed page |
Escape | In PaginationJump, restore the committed page |
There is no roving tabindex and no arrow-key model. APG has no pagination pattern, and every control here is independently meaningful — often a link. Roving would hide all but one of them from keyboard users and fight the screen-reader virtual cursor. Every surveyed implementation tabs through.
Assistive technology
| Reader | Status |
|---|---|
| VoiceOver (macOS, Safari) | Verified. Tabbing in reads "Go to page 1, button, list 7 items"; activating it announces "Page 1 of 10". The page-size control reads "20 items per page, list box pop up, collapsed, combo box", arrows into its options as "50 (3 of 4)", and reads the new value back on selection |
| NVDA | Untested — no Windows machine available |
| JAWS | Untested — no Windows machine available |
The announcement is doubled under next dev because React Strict Mode
double-invokes effects; a production build announces once.
Migrating
- Boundary Previous/Next are no longer natively
disabled. Selectors or tests matching:disabled/[disabled]on them must target[aria-disabled="true"]. Whole-componentdisabledis unchanged. - The landmark's default name is now
"Pagination", not"pagination navigation". Passaria-labelto pin your own. - Disabled opacity moved from 40% to 50%, matching the rest of the library. Boundary dimming stays at 40%.
PaginationSkeleton.showNavis gone — usenavStyle="none".- The default layout is now responsive. If you depend on the number row at
every width, set
layout="numbers"explicitly — and note that it trades the 44px touch target for the compact density on phones in order to stay on one line.
Notes
- Truncation uses the MUI
usePaginationalgebra:boundaryCountpages pinned at each end,siblingCountaround the current page, the rest collapsed. When a marker would stand for exactly one page, that page is printed instead. - Controlled vs uncontrolled is decided once, at mount. Switching between them mid-life warns in development rather than silently mirroring state.
- No headless dependency. Base UI has no pagination primitive; this is plain React state over the house Button/Select tokens.