DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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

Usage

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

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""ltr"Text direction — RTL flips chevrons and mirrors control order
prevLabelstring"Previous"Label for the Previous button (navStyle="label")
nextLabelstring"Next"Label for the Next button
firstLabelstring"Go to first page"Accessible name for the first-page button
lastLabelstring"Go to last page"Accessible name for the last-page button
aria-labelstring"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) => stringPage N of MFormats 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
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

PaginationRange

PropTypeDefaultDescription
page, pageSize, totalItemsnumberfrom RootOverride the Root's values
nounstring"items"Noun for the records
format({from, to, total}) => string—Full control of the string
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"Visible text beside the select
labelPlacement"start" | "end""end"Which side the label sits on
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"Text before the field
suffixReactNode | nullof {count}Text after the field; null removes it
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"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"ring-2 ring-inset ring-primary text-primary (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". Every control has a descriptive name, overridable through getPageAriaLabel.
  • 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 below md (WCAG 2.5.5) in auto and simple. 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)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
NVDAUntested — no Windows machine available
JAWSUntested — no Windows machine available

The announcement is doubled under next dev because React Strict Mode double-invokes effects; a production build announces once.

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 MUI usePagination 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.
  • 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.
OTP InputPicker

On this page

PlaygroundInstallationUsageWhen to use itExamplesDefaultResponsiveSimpleVariantsSizesRoundedNav stylesFirst and lastTruncationControlledTotal itemsRange labelPage sizeJump to pageToolbarLinksLoadingOn a colour plateRTLAPI ReferencePaginationPaginationRootPaginationRangePaginationPageSizeSelectPaginationJumpPaginationSkeletonLayoutsNav stylesVariantsAccessibilityKeyboardAssistive technologyMigratingNotes