Bridge view
Pagination
A navigation component for moving between discrete pages of a larger dataset — search results, table rows, document indices. Wrapped in a `<nav>` landmark with a specific `aria-label` ("Pagination"); current page marked with `aria-current="page"`; disabled previous / next controls retain DOM presence with `aria-disabled="true"` so the tab-order stays stable as users page through. Three structural variants — numbered (the page list), prev-next-only (single previous + next plus a "Page X of N" context), and load-more (single button that appends the next page below the current data).
Also called Pager Page navigation
When to use
Use
For datasets larger than a single viewport where users move between bounded pages — search results, table rows, document indices, blog archives. Use the numbered variant when the total page count is bounded and known (typically 1–50 pages) so users see absolute position. Use prev-next-only when the total is large or unknown (server logs, infinite-feeling feeds with checkpoints). Use load-more when the workflow favours append-style discovery (image galleries, social feeds) where users build up a longer continuous list rather than navigating discrete pages.
Avoid
For switching between sibling sections of one page — that is `Tabs`. For sequential workflows where step-N depends on step-N-1 — that is `Stepper`. For traversing site hierarchy — that is `Breadcrumbs`. For purely presentational page-of-N labels without navigation — render static text, not pagination. For client-side pagination over already-loaded data smaller than a screen — render the full list and use grouping or filtering instead.
Versus related
- table
`Pagination` partners with `Table` to chunk large datasets — pagination is the navigation control that sits adjacent to the table; the table is the data surface. They appear together but are distinct components: pagination handles "which page" while the table handles "how the data renders". Use both together for paginated tables; use Table alone when all rows fit (or when virtualization replaces pagination as the size-handling strategy).
- tabs
`Tabs` switches between sibling sections of the same logical page (overview / activity / files for one project); `Pagination` traverses pages of one logical list (results 1-20 / 21-40 / 41-60). Tabs surface different content under the same heading; pagination moves the same content's window across the dataset. The decision test: do the surfaces present different kinds of content (Tabs) or different windows on the same content (Pagination)?
- stepper
`Stepper` represents a linear sequence with state carried between steps (cart → address → payment); `Pagination` lets users move freely between pages of a list without sequence semantics. Stepper has progress meaning (steps before are completed, steps after are pending); pagination has only location meaning (page 3 of 12, no progress). Never use pagination for a flow.
- breadcrumbs
`Breadcrumbs` orients users within a site hierarchy (Home → Settings → Account); `Pagination` orients users within a single list's page-window. Breadcrumbs cross site contexts; pagination stays in one context. They often appear together — breadcrumbs at the top of the page, pagination below the data.
Pagination decomposes a large list into bounded pages so users scan in chunks rather than scrolling indefinitely. The reference documents the nav-landmark contract, the `aria-current="page"` rule on the current-page item (rendered as static or as a non- navigating link, never as a self-link to the current URL), the ellipsis-as-decorative discipline, and the disabled-not-removed contract that keeps tab-order stable across page transitions. Truncation uses Material's siblingCount + boundaryCount API as the canonical configuration. Distinct from Tabs (in-page section switching) and Stepper (linear progress) — pagination is navigation across page boundaries within one logical content surface.
Figma↔Code mismatches
Where designer and developer worlds typically misalign on this component.
- 01 Figma
Disabled previous / next drawn as removed or hidden from layout
Code`aria-disabled="true"` plus visually-disabled treatment, element stays in DOM
ConsequenceDesigners compose pagination by hiding the previous button on page 1 and the next button on the last page; developers keep the buttons in the DOM with visually-disabled styling. The two surfaces shape tab-order differently — designer's hide shifts the tab-order between pages (focus jumps land in different places), developer's keep preserves a stable tab-order. Without explicit anatomy, the disabled-not-removed contract is invisible in the design file and implementations may ship the wrong pattern.
CorrectDocument the disabled state as a structural slot property in the anatomy. Figma carries an "atFirst" and "atLast" state of the previous / next buttons with visually-disabled styling. Code ships `aria-disabled="true"` plus the disabled visual treatment without removing the element. Tab-order stays stable across page transitions.
- 02 Figma
Page-numbers drawn as styled text without explicit link affordance
Code`<a href="?page=N">` (server-render canonical) or `<button>` (SPA)
ConsequenceDesigners author page-numbers as inline text styled to look clickable; developers wrap them as `<a>` (server-render) or `<button>` (SPA). The two surfaces diverge on URL semantics — anchor preserves right-click + open-in-new-tab + share-link, button breaks all three. Without explicit anatomy, the design file does not communicate the URL contract and implementations may ship buttons-everywhere by default.
CorrectDocument the page-button as `<a href>` for server-paginated tables (the canonical default) and `<button>` only for SPA-only contexts where URL change is undesirable. Figma carries the link affordance in the page-button instance — hover- underline, accent color, visible focus ring on focus-visible. Code ships `<a href="?page=N">` whenever the page can be deep-linked.
- 03 Figma
Ellipsis drawn as decorative dots
Code`<span aria-hidden="true">…</span>`, not a tab stop
ConsequenceDesigners draw "…" as static text between page boundaries; developers ship it as either a non- interactive span (canonical) or as a clickable jump-button (Material UI's non-canonical extension). The two implementations diverge on keyboard behaviour — span has no tab stop, button does. Without canonical anatomy, the design surface cannot disambiguate and implementations may ship the ellipsis-as-button pattern that confuses keyboard users.
CorrectDocument the ellipsis as a decorative slot with `aria-hidden="true"` and no tab stop. Figma carries the ellipsis as a static text element, not a button instance. Code ships `<span aria-hidden="true">` or a CSS pseudo-element. Implementations that want jump-to-page behaviour should add a separate visible "Jump to page" input, not overload the ellipsis.
- 04 Figma
Current-page styled identically to other page-numbers
Code`<a aria-current="page">` with distinct visual treatment
ConsequenceDesigners draw the current page with the same hover- style as other page-numbers; developers add `aria-current="page"` and a distinct visual treatment (filled background, bolder weight). The two surfaces visually under-communicate the current state — sighted users may not see "I am here" if the design file does not carry an explicit current variant.
CorrectDocument the current-marker as a distinct slot with explicit visual treatment (filled background, bolder weight, accent color) AND `aria-current="page"` semantic. Figma carries a "current" variant of the page-button instance with non-link styling and the current-state visual treatment. Code wires `aria-current="page"` and removes the href so the element is not a self-link.
- 05 Figma
Load-more variant drawn identically to numbered pagination
CodeDistinct anatomy — single button replaces items-list, previous, next entirely
ConsequenceDesigners compose load-more pagination as a number-list with a "Load more" button at the end; developers ship it as a single button with no number-list. The two surfaces diverge structurally — load-more's data model is append (rows accumulate), numbered's is replace (rows swap per page). Without explicit variant anatomy, the design surface cannot communicate which model the implementation should use.
CorrectDocument load-more as a distinct variant with load-more-button as the canonical interactive affordance and result-summary / page-context-text replacing the items-list. Figma carries a "Load more" variant of the pagination component with its own composition. Code ships `<button>Load more</button>` plus an `aria-live` region announcing the row-count delta on append.
Variants, properties, states
Variants
Structurally different versions of the component.
numbered prev-next-only load-more Properties
The same component, parameterised.
| Property | Type |
|---|---|
size | sm | md |
showFirstLast | boolean |
disabled | boolean |
States
Browser/user-driven (interactive) vs. app-driven (data).
| Kind | States |
|---|---|
interactive | hoverfocus-visibleactivedisabled |
data | idleloadingatFirstatLastsinglePage |
Figma ↔ Code property map
| Figma | Kind | Code | Notes |
|---|---|---|---|
Variant | Enum | variant | Maps numbered / prev-next-only / load-more. |
Size | Enum | size | sm / md. |
Current Page | Number | currentPage | 1-based current page index. Drives `aria-current="page"` placement and atFirst / atLast state. |
Total Pages | Number | totalPages | 1-based total page count. Drives the visible page-button range and atLast state. |
Sibling Count | Number | siblingCount | Number of page-buttons shown adjacent to the current page (default 1; Material UI convention). |
Boundary Count | Number | boundaryCount | Number of page-buttons shown at the start / end of the range before ellipses appear (default 1; Material UI convention). |
Show First Last | Boolean | showFirstLast | When true, first / last boundary-jump buttons render at the extremes of the items-list. |
Disabled | Boolean | disabled | Whole pagination is non-interactive; all buttons render with `aria-disabled="true"`. |
Page Size | Number | pageSize | Items-per-page when the page-size-selector slot is authored. Pairs with pageSizeChange event. |
Get Item Aria Label | Text | getItemAriaLabel | i18n callback that returns the accessible name for each item ("Go to page N", "Previous page", etc.). |
State transitions
| From | To | Trigger |
|---|---|---|
idle | loading | User activates a page-button / previous / next / load-more on a server-paginated dataset |
loading | idle | Async fetch resolves with new page data |
idle | atFirst | User navigates to page 1 (previous-button + first-button become aria-disabled) |
idle | atLast | User navigates to the last page (next-button + last-button become aria-disabled) |
Figma anatomy
| Slot | Figma type | Hint |
|---|---|---|
nav-root | frame | Auto-layout horizontal frame; navigation landmark variant property |
items-list | frame | Horizontal auto-layout list; gap small; reset list-marker |
previous-button | instance | Button instance with leading chevron-left icon plus optional "Previous" label |
next-button | instance | Button instance with trailing chevron-right icon plus optional "Next" label |
page-button | instance | Page-number button instance with default / hover / focus-visible / current variants |
current-marker | instance | Current-page button variant; non-link styling; aria-current state |
ellipsis | text | Decorative ellipsis ("…") character; not a button instance |
first-button | instance | Button instance with double-chevron-left icon |
last-button | instance | Button instance with double-chevron-right icon |
page-context-text | text | Inline text "Page X of N" or "Showing N-M of T"; static text style |
load-more-button | instance | Button instance with optional "Loading..." state and progress indicator |
page-size-selector | instance | Select instance with page-size options |
Code anatomy
| Slot | Code slot | Semantic |
|---|---|---|
nav-root | nav-root | nav-with-aria-label |
items-list | items-list | ul |
previous-button | previous-button | a-or-button |
next-button | next-button | a-or-button |
page-button | page-button | a-or-button |
current-marker | current-marker | a-with-aria-current |
ellipsis | ellipsis | span-aria-hidden |
first-button | first-button | a-or-button |
last-button | last-button | a-or-button |
page-context-text | page-context-text | span-or-aria-live |
load-more-button | load-more-button | button |
page-size-selector | page-size-selector | select |
Cross-framework expression
| Framework | Structure mechanism | Variant mechanism |
|---|---|---|
| Web Components | A `<ui-pagination>` host with `variant`, `size`, `current-page`, `total-pages`, `sibling-count`, `boundary-count`, `show-first-last`, `disabled` attributes; renders the navigation landmark plus the items-list with computed page-buttons + ellipses based on the truncation algorithm. The wire-name `ui-pagination` becomes `md-pagination` / `mat-paginator` / `pf-pagination` per design system; the canonical wire-name stays `pagination`. Slotted `previous-label`, `next-label`, `page-label-template` for i18n customisation. | Attributes drive variant + properties (`variant="numbered"`, `size="md"`, `show-first-last`, `disabled`); `current-page` and `total-pages` reflect to attributes for declarative state. CSS uses logical properties so RTL flips chevron direction. Page-change emits `page-change` CustomEvent. |
| React | Compound component (Material UI Pagination, Atlassian Pagination, Carbon Pagination, Polaris Pagination as third-party precedents). React Aria ships no Pagination primitive — consumers compose `<a>` / `<button>` per page using the underlying `useFocusRing` and `usePress` hooks. Material UI exposes `count`, `page`, `siblingCount`, `boundaryCount`, `showFirstButton`, `showLastButton`, `onChange(event, page)` as the canonical API surface. The compound shape (`<Pagination.Previous>`, `<Pagination.Item>`) exists in HeroUI / Mantine. | Props with class-variance-authority for variant + size classes; `siblingCount` + `boundaryCount` drive the truncation algorithm. `getItemAriaLabel` callback for i18n of "Go to page N" labels. Server-side rendering pairs with framework `<Link>` (Next.js, React Router) so the rendered DOM is `<a href>` not `<button>`. |
| Angular (signals) | Angular component with `input<PaginationVariant>('variant')`, `input<number>('currentPage')`, `input<number>('totalPages')`, `input<number>('siblingCount')`, `input<number>('boundaryCount')`, `input<boolean>('showFirstLast')`, `input<boolean>('disabled')`. Angular Material ships `MatPaginator` with the same surface plus `pageSize` / `pageSizeOptions` for the page-size- selector slot. Page-change via `output<{ pageIndex: number, pageSize: number }>('pageChange')` plus optional two-way `model<number>('currentPage')`. | Host-binding `[attr.role]="'navigation'"` plus `[attr.aria-label]`; signal-driven reactive truncation — `computed()` derives the visible page-numbers from `currentPage()`, `totalPages()`, `siblingCount()`, `boundaryCount()`. CSS variables drive size scaling so consumers override per-instance. |
| Vue | Single-file component with default + named slots for previous-label / next-label / item-template / ellipsis customisation. PrimeVue Paginator, Element Plus Pagination, Naive UI Pagination as third-party precedents. `defineProps` exposes `currentPage` (v-model), `totalPages`, `siblingCount`, `boundaryCount`, `showFirstLast`, `disabled`. Page-change via `@update:current-page` (v-model:currentPage). | `defineProps` with literal-union types (`variant: 'numbered' | 'prev-next-only' | 'load-more'`); v-model:currentPage and v-model:pageSize for two-way state binding. Slot-based item-template lets consumers override per-page-button rendering without prop-drilling. |
Events
pageChangepageSizeChangeoptional
Form integration
- name attribute
- Pagination is a navigation component, not a form control. The `<nav>` element has no `name` attribute and contributes nothing to FormData. Page state lives in the URL query (server-rendered canonical) or in component state (SPA fallback) — not in the form payload.
Internationalisation
RTL · mirroring
Chevron icons (chevron-left for previous, chevron-right for next, double-chevron for first / last) flip direction under RTL — visual previous becomes right-pointing in Arabic / Hebrew. Implementation via CSS logical properties (`inset-inline-start`) or via icon components that mirror via the `direction` cascade. Page-numbers do not flip (digits read left-to-right even in RTL contexts via bidi). Items-list flow direction follows document direction so the leading item (typically previous-button) sits at the inline-start. Nav-landmark `aria-label` translates per locale.
Text expansion
Page-button labels expand under translation only when text labels are authored ("Previous" → "Précédent" → "السابق") — icon-only buttons are stable. "Page X of N" expands 30-50% ("Seite 3 von 12", "Page 3 sur 12"); reserve flexible space in the page-context-text slot. Number formatting via `Intl.NumberFormat` handles locale-specific thousand-separators (1,000 → 1.000 in German). For very long total-page-counts (10,000+ pages), the prev-next-only variant avoids visual crowding regardless of language.
Accessibility
| Slot | Accessibility hint | |
|---|---|---|
nav-root | `<nav aria-label="Pagination">` is canonical. Do not omit the label — a page may carry multiple nav landmarks (primary nav, breadcrumbs, in-page TOC, pagination). Translate the label per locale ("Paginierung" for German, "Pagination" for French) so SR announces it naturally. | |
items-list | `<ul>` reset to no markers and no indent. Each previous / page / ellipsis / next item is wrapped in `<li>`. Some implementations omit the list and render flat anchors — this works visually but loses the "list of N items" announcement that helps SR users orient. | |
previous-button | Accessible name: "Previous" or "Go to previous page" (icon-only buttons require `aria-label` because the chevron alone is not a name). When disabled, set `aria-disabled="true"` AND keep the element in the DOM with `tabindex="0"` (or default for `<button>`) so the tab-order is stable. Disabled controls announce "Previous, dimmed" / "unavailable"; users learn the pattern stays put across page transitions. | |
next-button | Mirrors previous-button. Accessible name "Next" or "Go to next page". When on the last page, `aria-disabled="true"` plus visually-disabled treatment; element stays in the DOM. Some libraries add `aria-hidden` on disabled state — never do this because the user loses the announcement that there is no next page. | |
page-button | Accessible name "Page N" (or "Go to page N") not just "N" — SR users hear isolated numbers without context otherwise. The current page is rendered via the current-marker slot (separate, with `aria-current="page"`); page-buttons are non-current and never carry `aria-current`. Visible focus ring per WCAG 2.4.7. | |
current-marker | `aria-current="page"` is canonical. The element SHOULD NOT be `<a href="?page=3">` when already on page 3 — `aria-current` plus a self-link creates a useless tab stop and the page-reload-on-click is hostile. Render as `<a aria-current="page">` without `href`, or as `<span aria-current="page">`. SR announces "current page, Page 3". | |
ellipsis | `<span aria-hidden="true">…</span>` or render via CSS pseudo-element. Never make ellipsis interactive in canonical pagination — Material UI's "ellipsis-becomes-button-on-click-to-jump" is a non-canonical extension that confuses keyboard users (extra tab stops with unclear behaviour). SR users hear the boundary pages and current page; ellipsis is visual scaffolding only. | |
first-button | Accessible name "Go to first page" — the double- chevron icon needs an explicit `aria-label`. Same disabled-not-removed contract as previous-button; AT users hear "first page, dimmed" on page 1. | |
last-button | Accessible name "Go to last page". Mirrors first- button rules. Total page-count is required input for this affordance — do not author last-button when total is unknown (open-ended feeds). | |
page-context-text | For prev-next-only variant, wrap in `<span aria-live="polite">` so SR announces page changes without forcing focus. Number formatting follows locale (`Intl.NumberFormat`). The text serves as an `aria-labelledby` target for prev / next buttons in some implementations (e.g., next button labelled "Next page, currently on page 3 of 12"). | |
load-more-button | `<button>` with accessible name "Load more" or "Show more results". On activation, loading state sets `aria-busy="true"` until fetch resolves; on resolve, focus moves to the first newly-appended row OR an `aria-live` region announces the row count delta ("20 more results loaded, 60 total"). Without one of these signals, SR users are stranded — they activate the button but cannot find the new content. | |
page-size-selector | Accessible name "Rows per page" via `<label>` association. Native `<select>` carries the right keyboard model and AT semantics without ARIA. Activating fires the pageSizeChange event; consumer typically resets to page 1 on change to avoid showing an empty page. |
Accessibility acceptance
Keyboard walk
| Keys | Expected |
|---|---|
Tab | Focus moves through the pagination items in DOM order — first-button (if present), previous- button, page-buttons (with current-marker included as a tab-stop unless rendered as non-interactive span), next-button, last-button (if present), page-size-selector (if present). Disabled buttons retain their tab-stop position; focus lands on them and SR announces "dimmed". |
Shift+Tab | Reverse focus through the same items. Standard tab-order — no pagination-specific behaviour. |
Enter / Space (focus on page-button / previous / next) | Activates the button. For `<a href>` page- buttons, Enter navigates (Space does nothing — anchors do not respond to Space by spec). For `<button>`, both Enter and Space activate. pageChange fires. |
Enter / Space (focus on disabled button) | No-op. The element is in DOM with `aria-disabled="true"` so AT announces "unavailable"; activation is suppressed via `event.preventDefault` in the handler. Tab-order remains stable. |
Enter / Space (focus on load-more-button, load-more variant) | Activates the load-more action. `aria-busy="true"` while fetching; on resolve, focus moves to the first newly-appended row OR an `aria-live` region announces the delta. Without one of these signals, keyboard users are stranded. |
Screen-reader announcements
| Trigger | Expected |
|---|---|
| SR enters the pagination landmark | SR announces "navigation, Pagination" (the landmark plus its label). For the numbered variant, the announcement continues with "list, N items" (the items-list) and the user can navigate from there. |
| SR encounters the current-marker | SR announces "current page, Page 3" — the `aria-current="page"` semantic plus the accessible name. Distinct from page-buttons which announce "Page 4, link". |
| SR encounters a disabled previous / next | SR announces "Previous, dimmed" or "unavailable". The element retains its position in the announcement stream so users learn the pattern stays put. |
| User activates a page-button (server-paginated) | Server fetch begins. `aria-busy="true"` on the nav-root or on the data surface; SR may announce "loading" via the live-region. On resolve, the new page renders and `aria-current="page"` moves to the activated button. Focus stays on the activated button so users can continue navigation without re-finding their place. |
| User activates load-more-button (load-more variant) | SR announces "loading" (via aria-busy or live- region). On resolve, an aria-live region announces the row-count delta ("20 more results loaded, 60 total"). Focus moves to the first newly-appended row OR stays on the load-more button (per the implementation's chosen pattern; both are canonical, document the choice). |
axe-core rules to assert
aria-allowed-attraria-required-attraria-valid-attr-valuelandmark-uniquelink-namebutton-namecolor-contrast
Same data as JSON for direct ingestion into Playwright + @axe-core/playwright or Jest + jest-axe:
/api/components/pagination/a11y-fixture.json
Contracts
Non-negotiable contracts
WCAGWCAG 1.3.1 Info and Relationships Pagination is wrapped in `<nav>` with a specific `aria-label` ("Pagination" or the locale- translated equivalent). The label distinguishes the pagination from other navigation landmarks on the page.
Without the landmark, SR users cannot jump to the pagination via landmark navigation — the affordance becomes invisible to AT until users encounter it by accident in linear reading order. WCAG 1.3.1 violation.
WCAGWCAG 4.1.2 Name, Role, Value The current page carries `aria-current="page"` and is rendered as static text or as a non- navigating anchor — never as `<a href>` to the current URL. Implementations that ship a self- link create a useless tab stop and the page-reload-on-click is hostile.
Without `aria-current="page"`, SR users hear "Page 3, link" on every page-button without any indication of which page they are currently viewing. Self-link to current page wastes user effort and breaks the navigation model.
Canon Disabled previous / next / first / last buttons retain DOM presence with `aria-disabled="true"` — never removed via `display: none` or omitted from rendering on boundary pages.
Without the rule, the pagination's tab-order shifts between pages — users learn one keyboard pattern then have it broken on transitions. AT users hear different element-counts in the landmark across page boundaries. The disabled-not-removed contract is canonical.
Canon Ellipses are decorative — `aria-hidden="true"` and not in the tab-order. Implementations that ship ellipsis-as-button (Material UI's non-canonical extension) confuse keyboard users with extra tab stops and unclear behaviour.
Without the rule, keyboard users encounter intermediate tab stops between boundary pages and sibling pages with no clear semantic. AT users hear "ellipsis, button" with no affordance contract. The decorative-ellipsis discipline is canonical.
HTML specHTML standard — anchor element semantics for navigation Page-numbers are `<a href>` for server-paginated or URL-deep-linkable contexts; `<button>` only for SPA-only contexts where URL change is undesirable. SPA framework `<Link>` components (Next Link, React Router Link) render as anchors and satisfy the rule.
Without the rule, right-click → "Open in new tab" does nothing; hover shows no URL preview; share-link copies the wrong URL. The affordance functions for keyboard / click but loses every URL-based interaction users expect from page navigation.
Vocabulary drift
- WAI-ARIA
aria-current="page" + nav-landmark- WAI-ARIA does not ship a formal Pagination APG pattern — pagination composes from the nav landmark (`role="navigation"`) plus `aria-current="page"` on the current page. The canonical anatomy follows this composition rather than inventing pagination-specific ARIA roles.
- Material 3
Pagination (Material UI extension)- Material 3 spec does not include Pagination as a first-class component — it ships as a Material UI extension. The canonical siblingCount + boundaryCount truncation API derives from Material UI's `Pagination` component (`count` / `siblingCount` / `boundaryCount` / `showFirstButton` / `showLastButton`). Mobile-first Material guidance favours infinite-scroll over pagination for content feeds.
- Carbon
Pagination- Carbon ships Pagination with `page` / `pageSize` / `pageSizes` (array of page-size options) / `totalItems` API. Pairs natively with Carbon DataTable. Adds explicit page-size-selector + result-summary as first-class anatomy (canonical optional slots).
- Atlassian
Pagination- Atlassian ships Pagination with `pages` (array of pages) + `selectedIndex` + `onChange(event, newSelectedPage)` API. Naming-only divergence from Material's page-as-number convention.
- Polaris
Pagination- Polaris ships Pagination with `hasPrevious` / `hasNext` / `onPrevious` / `onNext` API — prev-next-only by design, not numbered. The canonical prev-next-only variant captures this Polaris-style as a first-class axis.
- GitHub Primer
Pagination- Primer ships Pagination with `pageCount` / `currentPage` / `onPageChange` API plus `marginPageCount` (Primer's name for boundaryCount) and `surroundingPageCount` (Primer's name for siblingCount). Naming- only divergence; surfaces map to canonical boundaryCount + siblingCount.
- GOV.UK
Pagination- GOV.UK ships Pagination with the numbered variant plus explicit "Previous page" / "Next page" labels (text not icon-only). Block-level pattern with previous + next as separate large blocks; canonical nav-root + items-list + previous / next composition matches.
- React Aria
— (no Pagination primitive)- React Aria ships no Pagination component. Consumers compose pagination from `<a>` / `<button>` plus `useFocusRing` + `usePress` hooks. The explicit absence is itself canonical — pagination is a composition over standard navigation elements, not a new ARIA pattern.
Common mistakes
#pagination-no-nav-landmark
Pagination is not wrapped in `<nav>` with `aria-label`
The pagination renders as a bare `<ul>` or a sequence of inline buttons without an enclosing landmark. SR users navigating by landmark cannot locate the pagination; the affordance is invisible to AT until users read every page-button by accident.
Wrap the pagination in `<nav aria-label="Pagination">` (or the locale-translated equivalent). The label must be specific — generic "Navigation" collides with the primary nav landmark on the page. AT users jump to "navigation, Pagination" via landmark shortcuts.
#pagination-current-no-aria-current
Current page is not marked with `aria-current="page"`
The current page is visually distinguished (filled background, bolder weight) but the programmatic state is missing — no `aria-current="page"` attribute. SR users hear "Page 3, button" on every page-button without any indication of which page they are currently viewing. WCAG 4.1.2 Name-Role-Value violation.
Set `aria-current="page"` on the current page- button. The element should be a non-navigating anchor (`<a aria-current="page">` without href) or a static span — never `<a href="?page=3">` when already on page 3. SR announces "current page, Page 3"; sighted users see the current visual treatment.
#pagination-ellipsis-focusable
Ellipsis is a tab stop or interactive element
The ellipsis between boundary pages is rendered as a button or as a focusable element with `tabindex` — Tab lands on it, SR announces it as "ellipsis, button". The behaviour is unclear (does activating jump to a specific page? open a menu?) and inconsistent across implementations.
Render the ellipsis as a non-interactive decorative element — `<span aria-hidden="true">…</span>` or a CSS pseudo-element. Remove any `tabindex`, `role="button"`, or click handler. Keyboard users navigate from boundary pages to sibling pages directly without an intermediate stop.
#pagination-disabled-removed-from-tab-order
Disabled previous / next is removed from the DOM or hidden
On page 1 the previous-button is removed from the DOM (or styled `display: none`); on the last page the next-button is removed. The pagination's tab- order shifts between pages — focus lands on the first page-number on page 1 but on the previous- button on page 2. Users learn one keyboard pattern then have it broken on page transitions.
Keep disabled previous / next buttons in the DOM with `aria-disabled="true"` plus visually-disabled styling. The element retains its tab-stop position and AT announces "previous, dimmed" / "unavailable". Tab-order stays stable across page transitions. Same rule applies to optional first / last boundary-jump buttons.
#pagination-not-link
Page-numbers are `<button>` even when URLs are deep-linkable
Server-rendered pagination ships page-numbers as `<button>` elements with onClick navigation handlers. Right-click → "Open in new tab" does nothing; hovering shows no URL preview; share-link copies the current page URL not the target page. The affordance functions for keyboard / click activation but loses every URL-based interaction pattern users expect from page navigation.
Render page-numbers as `<a href="?page=N">` for server-rendered or URL-deep-linkable pagination. Reserve `<button>` for SPA-only contexts where URL change is undesirable (in-app modals, ephemeral views). For SPA frameworks with client- side routing, use the framework's `<Link>` / `<a>` component (Next Link, React Router Link) so the rendered DOM remains an anchor.
#pagination-no-page-context
Pagination provides no SR context for current position
The pagination renders only icon-based previous / next buttons (no text labels, no aria-label, no "Page X of N" text). SR users hear only "previous" / "next" with no indication of where they are or where they are going. Common in icon-only minimalist designs that prioritize visual cleanliness over information density.
Add `aria-label` to icon-only buttons ("Go to previous page", "Go to next page") and provide page-context-text either visibly ("Page 3 of 12") or via sr-only span. For prev-next-only variant, the page-context-text is required, not optional. The text wraps in `<span aria-live="polite">` so page changes announce on transition without stealing focus.