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.
pnpm add @tessinaui/uiWhen to use
- A finite set the reader moves through. Search results, a data table, an archive: places where people need to know where they are and how much is left.
- Content too large to load or show at once. Pages keep each load small and the view short enough to scan.
- Jumping to a specific point. The number row lets a reader go straight to a
page near either end;
showFirstButtonandshowLastButtonadd jumps to the ends. - Pages that can be linked to. Pass
getHrefso each page is a real anchor that can be opened in a new tab or crawled. - Previous and next only, when order matters.
layout="simple"shows only the Previous and Next buttons around a "Page N of M" counter.
When not to use
- For browsing. Image grids, activity feeds and mobile timelines do better with infinite scroll or a "Load more" button. A "Load more" control is a Button, not a degenerate pager.
- For steps that must happen in order. A form or checkout is a journey, not a list; use Continue and Back buttons, with a segmented Progress when position matters.
- For slides. Use a Carousel.
- When everything fits on one page. Splitting short content adds clicks and
no benefit; set
hideOnSinglePageso a one-page set shows no pager. - Where the table is the whole job. Sorting, filtering and paging together are DataGrid's; compose Pagination yourself only for a plain Table.
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}`} />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: on a touch screen every item is floored to a 44px 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. An explicit
dir="rtl" defaults the phrase and its noun to Arabic ("1–20 من 128 عنصرًا").
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.
The current page is the inverse chip in default and primary alike: a quiet
fill cannot carry a label on a plate, so the two meet there, and outline keeps
its ring.
Every plate colour works the same way:
RTL
dir="rtl" flips the chevrons and mirrors the control order, so the largest
jump stays outermost in the reading direction. Leave dir unset and the
pagination reads the direction it inherits from the page. The range label and
the "Page N of M" counter are isolated in their own language's direction, so an
English phrase inside an RTL row still reads "1–10 of 100 items".
An explicit dir="rtl" also defaults every string the pager shows or
announces to Arabic: the landmark name, Previous and Next, the buttons' and
pages' names, the ellipses, the counter, the range, the page-size label and the
jump field. Set on a PaginationRoot, it reaches every part inside that has no
dir of its own. An inherited direction mirrors the layout and keeps English.
Each label prop still overrides its default, and strings overrides any other
entry of PAGINATION_STRINGS.
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" | inherited | Text direction — RTL flips chevrons and mirrors control order. Unset, read off the page |
prevLabel | string | "Previous", Arabic under dir="rtl" | Label for the Previous button (navStyle="label") |
nextLabel | string | "Next", Arabic under dir="rtl" | Label for the Next button |
firstLabel | string | "Go to first page", Arabic under dir="rtl" | Accessible name for the first-page button |
lastLabel | string | "Go to last page", Arabic under dir="rtl" | Accessible name for the last-page button |
aria-label | string | "Pagination", Arabic under dir="rtl" | 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, Arabic under dir="rtl" | Formats the counter and the announcement |
strings | Partial<PaginationStrings> | — | Overrides single entries — the icon buttons' and pages' names, the ellipses, the counter. The props above win |
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 |
dir | "ltr" | "rtl" | inherited | "rtl" also gives every part without its own dir the Arabic strings |
strings | Partial<PaginationStrings> | — | Overrides single entries for every part inside |
PaginationRange
| Prop | Type | Default | Description |
|---|---|---|---|
page, pageSize, totalItems | number | from Root | Override the Root's values |
noun | string | "items", Arabic under dir="rtl" | Noun for the records |
dir | "ltr" | "rtl" | inherited | "rtl" defaults the phrase to Arabic; unset, the Root's dir decides and an inherited one keeps English |
format | ({from, to, total}) => string | — | Full control of the string |
strings | Partial<PaginationStrings> | — | Overrides range, empty or noun; noun and format win |
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", Arabic under dir="rtl" | Visible text beside the select |
labelPlacement | "start" | "end" | "end" | Which side the label sits on |
dir | "ltr" | "rtl" | inherited | "rtl" defaults the label to Arabic; unset, the Root's dir decides |
strings | Partial<PaginationStrings> | — | Overrides single entries; label wins |
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", Arabic under dir="rtl" | Text before the field |
suffix | ReactNode | null | of {count}, Arabic under dir="rtl" | Text after the field; null removes it |
dir | "ltr" | "rtl" | inherited | "rtl" defaults the text and the field's name to Arabic; unset, the Root's dir decides |
strings | Partial<PaginationStrings> | — | Overrides single entries, the field's name included; label and suffix win |
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", Arabic under dir="rtl" | 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" | inset-ring-2 inset-ring-primary text-primary-tinted-foreground (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"and is named just "Page 5"; screen readers add "current page" from the attribute, so VoiceOver reads "Page 5 current page button" once. Every control has a descriptive name, overridable throughgetPageAriaLabel— a custom label for the selected page should not repeat "current page" either. - 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 on a touch screen — a phone, or a tablet
at any width (WCAG 2.5.5) — in
autoandsimple. A mouse or trackpad keeps the compact density. 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) | Passed 2026-09-14. Focusing next reads "Go to next page button, Pagination navigation"; each press announces the new page exactly once: "Page 4 of 10", then "Page 5 of 10". The 2026-09-13 run heard every announcement twice, which the <div aria-live> fix resolved. The page-size Select was re-checked on 2026-09-08. Transcript: tests/qa/vo-transcripts/pagination.md |
| NVDA | Untested — no Windows machine available |
| JAWS | Untested — no Windows machine available |
The 2026-09-14 run used next dev with React Strict Mode on and still heard
each page once, so a doubled announcement is a regression, not a dev artefact.
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 standard pagination algebra:
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. - Give the pager a column at least as wide as its row.
layout="auto"chooses the number row by the window's width, not the column's, so in a narrow column (a sidebar, a card in a grid) it shows every number and overflows. Onlylayout="numbers"scrolls; nothing else in the pager wraps or shrinks. In a narrow column uselayout="simple"with the default icon navigation and no first and last buttons. - A long
numbersrow scrolls to the current page. Once the row overflows it starts at page 1, and it scrolls itself — never the page — to keep the current page in view. - 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.