DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
BreadcrumbTabsPaginationStepperMenu ItemToolbarMenubarNavigation MenuAccordionCollapsibleCommandBottom NavTop Header MobileTop Header DesktopSidebar
AI agentsContributing
  1. Components
  2. Pagination

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/ui

When 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; showFirstButton and showLastButton add jumps to the ends.
  • Pages that can be linked to. Pass getHref so 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 hideOnSinglePage so 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

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import { Pagination } from "@tessinaui/ui";
example.tsx
<Pagination count={10} defaultPage={1} />

Controlled, when the page lives in your own state:

example.tsx
<Pagination count={10} page={page} onChange={setPage} />

From a record count instead of a page count:

example.tsx
<Pagination totalItems={128} pageSize={20} />

With jumps straight to the ends:

example.tsx
<Pagination count={50} defaultPage={25} showFirstButton showLastButton />

As real links, for crawlable pagination:

example.tsx
<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

PropTypeDefaultDescription
countnumber—Total number of pages. Optional inside a PaginationRoot, or when totalItems is given
totalItemsnumber—Total record count; with pageSize, derives count
pageSizenumber10Records per page, used with totalItems
pagenumber—Controlled current page (1-indexed)
defaultPagenumber1Uncontrolled initial page
onChange(page: number) => void—Called when the active page changes
siblingCountnumber1Pages shown on each side of the current page
boundaryCountnumber1Pages 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"inheritedInk treatment; inherited from an enclosing Surface
navStyle"icon" | "label" | "none""icon"Navigation button style — see below
showFirstButtonbooleanfalseAdd a jump-to-first button outside Previous
showLastButtonbooleanfalseAdd a jump-to-last button outside Next
disabledbooleanfalseDisable all interactions (natively disabled)
loadingbooleanfalseA page change is in flight — busy, input refused, focus kept
hideOnSinglePagebooleanfalseRender nothing when there is one page or fewer
dir"ltr" | "rtl"inheritedText direction — RTL flips chevrons and mirrors control order. Unset, read off the page
prevLabelstring"Previous", Arabic under dir="rtl"Label for the Previous button (navStyle="label")
nextLabelstring"Next", Arabic under dir="rtl"Label for the Next button
firstLabelstring"Go to first page", Arabic under dir="rtl"Accessible name for the first-page button
lastLabelstring"Go to last page", Arabic under dir="rtl"Accessible name for the last-page button
aria-labelstring"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) => stringPage N of M, Arabic under dir="rtl"Formats the counter and the announcement
stringsPartial<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
classNamestring—Additional class on the root <nav>

PaginationRoot

Optional state owner. Wrap parts in it only when more than one has to agree.

PropTypeDefaultDescription
count / totalItemsnumber—Page count, or record count to derive it from
page / defaultPagenumber1Controlled / uncontrolled page
onPageChange(page: number) => void—Fired when any child moves the page
pageSize / defaultPageSizenumber10Controlled / 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
stringsPartial<PaginationStrings>—Overrides single entries for every part inside

PaginationRange

PropTypeDefaultDescription
page, pageSize, totalItemsnumberfrom RootOverride the Root's values
nounstring"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
stringsPartial<PaginationStrings>—Overrides range, empty or noun; noun and format win
size, tone—from Root

PaginationPageSizeSelect

PropTypeDefaultDescription
optionsnumber[][10, 20, 50, 100]Selectable page sizes
valuenumberfrom RootControlled value
onValueChange(size: number) => void—Falls back to the Root
labelstring"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
stringsPartial<PaginationStrings>—Overrides single entries; label wins
size, rounded, tone, disabled—from Root

PaginationJump

PropTypeDefaultDescription
page, countnumberfrom RootOverride the Root's values
onChange(page: number) => void—Falls back to the Root
labelstring"Page", Arabic under dir="rtl"Text before the field
suffixReactNode | nullof {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
stringsPartial<PaginationStrings>—Overrides single entries, the field's name included; label and suffix win
size, rounded, tone, disabled—from Root

PaginationSkeleton

PropTypeDefaultDescription
sizePaginationSize"md"Matches the real pager's box
roundedPaginationRounded"full"
countnumber5Number of placeholder page blocks
navStylePaginationNavStyle"icon"Mirrors the real navStyle
layoutPaginationLayout"auto"Mirrors the real layout
showFirstButton / showLastButtonbooleanfalse
page, siblingCount, boundaryCountnumber1Decide where the ellipses fall
labelstring"Loading", Arabic under dir="rtl"What the live region says while loading

Layouts

ValueDescription
"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

ValueDescription
"icon"Chevron icon buttons only — [<] [pages] [>] (default)
"label"Previous / Next text buttons with icons
"none"No navigation buttons — page items only

Variants

ValueActive 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 redundant role="navigation" — <nav> already carries it, and the old "pagination navigation" label made screen readers say "navigation" twice. Give each pager its own aria-label when 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 through getPageAriaLabel — 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 natively disabled. 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-only text 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 auto and simple. A mouse or trackpad keeps the compact density. This is why auto swaps 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. Prefer auto unless you specifically need every page number visible on a phone.
  • PaginationSkeleton is a role="status" region with real text, so it announces on insertion instead of being an empty application status.

Keyboard

KeyAction
Tab / Shift+TabMove through the controls in visual order
Enter / SpaceActivate the focused control
EnterIn PaginationJump, commit the typed page
EscapeIn 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

ReaderStatus
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
NVDAUntested — no Windows machine available
JAWSUntested — 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

  1. Boundary Previous/Next are no longer natively disabled. Selectors or tests matching :disabled / [disabled] on them must target [aria-disabled="true"]. Whole-component disabled is unchanged.
  2. The landmark's default name is now "Pagination", not "pagination navigation". Pass aria-label to pin your own.
  3. Disabled opacity moved from 40% to 50%, matching the rest of the library. Boundary dimming stays at 40%.
  4. PaginationSkeleton.showNav is gone — use navStyle="none".
  5. 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: boundaryCount pages pinned at each end, siblingCount around 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. Only layout="numbers" scrolls; nothing else in the pager wraps or shrinks. In a narrow column use layout="simple" with the default icon navigation and no first and last buttons.
  • A long numbers row 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.
TabsStepper

On this page

When to useWhen not to useInstallationUsageExamplesDefaultResponsiveSimpleVariantsSizesRoundedNav stylesFirst and lastTruncationControlledTotal itemsRange labelPage sizeJump to pageToolbarLinksLoadingOn a colour plateRTLAPI ReferencePaginationPaginationRootPaginationRangePaginationPageSizeSelectPaginationJumpPaginationSkeletonLayoutsNav stylesVariantsAccessibilityKeyboardAssistive technologyMigratingNotes