Pagination
Navigation landmark · aria-current=“page”
Page-by-page navigation of a long list, with the current page marked and controls that read clearly out of context.
Live example
Real and interactive — use it with the mouse, or Tab to it and use the keys below.
Search results — page 1 of 9
Directory — page 12 of 12
Click a page number, or Tab to one and press Enter/Space. Previous is disabled on page 1 of the first list; Next is disabled on the last page of the second — both stay in the tab order rather than disappearing, so a keyboard user always finds them in the same place.
Keyboard
| Key | Action |
|---|---|
| Tab / Shift + Tab | moves between Previous, each visible page number, and Next in visual order — a plain row of buttons, no arrow-key layer to learn |
| Enter or Space | on a focused page-number button, goes to that page; on Previous/Next, moves one page — does nothing when the focused control is aria-disabled |
Screen reader
- The landmark is announced by its label, which must be unique among any other navigation landmarks on the same page — otherwise two are indistinguishable by name alone.
- The list of page numbers announces its length before the user starts moving through it.
- The current page is announced as “page” via aria-current, not read as an ordinary button — there is nothing to activate because it is plain text, not a control.
- Previous and Next are announced by their visible text (“Previous”/“Next”), never by the arrow glyph alone, so the name is meaningful without seeing the icon.
- A disabled Previous or Next is announced as unavailable (aria-disabled) rather than silently missing from the tab sequence.
- The truncation “…” between page numbers is not announced at all — it carries no destination, so it is hidden from assistive technology instead of being read as a dead end.
ARIA notes
- nav needs aria-label (or aria-labelledby) whenever the page carries more than one navigation landmark — the same rule Breadcrumbs documents, and the same axe rule (landmark-unique) that catches a collision.
- aria-current=“page” takes the literal string “page”, not “true” — aria-current is an enumerated attribute (page/step/location/date/time/true/false) and “page” is the value meant for exactly this case.
- The current page is rendered as plain text, never a link/button pointing at itself — a control that only reloads where the user already is has no purpose and is easy to mistake for a real destination.
- Previous/Next disabled state uses aria-disabled on a still-focusable <button>, not the native disabled attribute — see the component's pitfalls for why.
Code
The real source of the example above — copy it and it works. This is the file that renders on this page, so the code and the live example can never drift apart.
// Accessible pagination — not a named WAI-ARIA APG pattern (unlike Combobox,
// Menu, or Listbox), which is exactly why it is easy to make LOOK right while
// being practically useless. This applies general navigation-accessibility
// principles to one specific job: moving through a long, page-broken list.
//
// The real traps, in the order people hit them:
//
// 1. LANDMARK NAME. The wrapper is `<nav aria-label={label}>` — a `<nav>`
// needs a NAME the moment a page has more than one (this site's Layout
// already renders "Main", "Breadcrumb", and "Legal" nav landmarks, and a
// component-library page adds its own "Components" prev/next nav below
// this one). `label` is a prop, not a hard-coded constant, for the same
// reason Breadcrumbs.tsx made the same call: uniqueness is a property of
// the PAGE a pagination control is mounted on, not of the component. A
// page showing two paginated lists side by side needs two distinct
// names, or axe's `landmark-unique` catches the collision — the same
// lesson Breadcrumbs already paid for (D-077).
// 2. `<ul>` INSIDE THE NAV. A row of page numbers is a list of items, and
// APG's own pagination example endorses wrapping them in one — the list
// announces its length ("list of 7 items") before the user starts
// moving through it, the same benefit Breadcrumbs gets from its `<ol>`.
// 3. THE CURRENT PAGE IS NOT A LINK TO ITSELF. A page number that is a
// `<button>`/`<a>` pointing at the page already showing is the
// "link that reloads what you're already looking at" antipattern —
// useless at best, and it silently eats the current position out of
// `aria-current` if not handled. This component renders the current
// page as plain text (an unstyled `<span>` inside its `<li>`), never a
// control, carrying `aria-current="page"` — the exact literal string
// "page", not `"true"`. `aria-current` is a small enumerated attribute
// (page/step/location/date/time/true/false) and "page" is the one
// built for this: it tells a screen reader "this is where you are in a
// set of pages", which the generic boolean does not.
// 4. BARE ARROW GLYPHS AS THE ONLY NAME. "‹"/"›" characters are decoration,
// not accessible names — a screen reader announces a button with no
// accessible text as just "button". Both the Previous and Next controls
// carry visible text containing the word, so the accessible name reads
// correctly with no reliance on an icon a screen reader cannot see.
// 5. DISABLED-BY-CSS-ONLY. Prev on page 1 / Next on the last page must be
// distinguishable as unavailable to more than sighted mouse users — see
// the `aria-disabled` note below for which mechanism and why.
// 6. A CLICKABLE ELLIPSIS. The "…" gap marker used when the page range is
// truncated is not a jump-to-page control here (no "type a page number"
// affordance exists to jump to what it represents) — it is rendered as
// `aria-hidden="true"` plain text, so it is never announced as an
// interactive item with no destination.
//
// KEYBOARD: every focusable element here — Previous, each page-number
// button, Next — is a real `<button>`. Tab / Shift+Tab already walk them in
// visual order for free; there is no roving-tabindex/arrow-key layer to
// build, unlike Tabs or Menu button. Confirmed deliberately, not assumed:
// this is a flat row of independent controls (each one either navigates or
// does nothing), not a single composite widget with one internal position —
// the roving-tabindex pattern exists for the latter case (Tabs' selected
// tab, a menu's active item), which this is not.
export interface PaginationProps {
/** 1-based index of the page currently showing. */
currentPage: number
totalPages: number
onPageChange: (page: number) => void
/** Landmark name — must be unique among the `<nav>` elements on whatever
* page this is mounted on (see note 1 above). Defaults to something
* reasonable for a page with only one paginated list. */
label?: string
/** How many page numbers to show on each side of the current page before
* collapsing the rest into an ellipsis. First and last page are always
* shown. */
siblingCount?: number
}
type PageItem = number | 'ellipsis-start' | 'ellipsis-end'
// Builds the truncated page list: 1, …, current-sibling..current+sibling, …,
// total — collapsing a run down to a single page number instead of a
// pointless one-page "gap" (e.g. skipping straight from page 1 to page 3
// would otherwise show "1 … 3" for a gap of exactly one page).
function buildPageItems(current: number, total: number, siblingCount: number): PageItem[] {
const totalNumbered = siblingCount * 2 + 5 // first + last + current + 2 ellipses worth of slack
if (total <= totalNumbered) {
return Array.from({ length: total }, (_, i) => i + 1)
}
const left = Math.max(current - siblingCount, 1)
const right = Math.min(current + siblingCount, total)
const items: PageItem[] = [1]
if (left > 2) items.push('ellipsis-start')
else if (left === 2) items.push(2)
for (let p = Math.max(left, 2); p <= Math.min(right, total - 1); p++) items.push(p)
if (right < total - 1) items.push('ellipsis-end')
else if (right === total - 1) items.push(total - 1)
items.push(total)
return items
}
export function Pagination({
currentPage,
totalPages,
onPageChange,
label = 'Pagination',
siblingCount = 1,
}: PaginationProps) {
const isFirst = currentPage <= 1
const isLast = currentPage >= totalPages
const items = buildPageItems(currentPage, totalPages, siblingCount)
// DISABLED STATE: `aria-disabled="true"` on a real, still-focusable
// <button>, deliberately NOT the native `disabled` attribute. The APG
// guidance on disabled controls prefers this when a control's disabled
// state is temporary/contextual rather than permanent: `disabled` removes
// the element from the Tab sequence entirely, so a screen-reader or
// keyboard-only user tabbing through the control row would find Previous
// simply *missing* on page 1 — nothing to explain why, and the row's tab
// stops shift depending on which page happens to be showing. Leaving it
// focusable with `aria-disabled` keeps the control row's shape constant
// and lets an assistive-technology user land on it and be told
// "dimmed"/"unavailable", which is more informative than an element that
// silently isn't there. The trade-off this accepts: a sighted mouse user
// tabbing past it also stops there — an acceptable cost for a two-button
// row. The click/keyboard handler below still has to enforce the "does
// nothing" half by hand, since aria-disabled is informational only and
// does not itself block a native <button>'s onClick.
function go(page: number) {
if (page < 1 || page > totalPages || page === currentPage) return
onPageChange(page)
}
return (
<nav aria-label={label}>
<ul className="flex flex-wrap items-center gap-1.5">
<li>
<button
type="button"
aria-disabled={isFirst || undefined}
onClick={() => {
if (isFirst) return
go(currentPage - 1)
}}
className={`inline-flex h-9 items-center gap-1 rounded-full border border-outline px-3 font-mono text-xs font-medium tracking-[0.05em] uppercase transition ${
isFirst
? 'cursor-not-allowed text-on-surface-variant opacity-50'
: 'text-on-surface hover:bg-surface-container'
}`}
>
<svg viewBox="0 0 16 16" aria-hidden="true" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.75">
<path d="M10 3 5 8l5 5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Previous
</button>
</li>
{items.map((item, idx) =>
typeof item === 'number' ? (
<li key={item}>
{item === currentPage ? (
// Current page: plain text, never a link/button pointing at
// itself — see note 3 in the file header.
<span
aria-current="page"
className="flex h-9 min-w-9 items-center justify-center rounded-full bg-primary px-2 font-mono text-xs font-medium text-on-primary num"
>
{item}
</span>
) : (
<button
type="button"
onClick={() => go(item)}
className="flex h-9 min-w-9 items-center justify-center rounded-full px-2 font-mono text-xs font-medium text-on-surface num transition hover:bg-surface-container"
>
{item}
</button>
)}
</li>
) : (
// Ellipsis: decorative only — it does not represent a clickable
// "jump to page" control, so it carries no interactive role and
// is hidden from assistive technology (note 6 above).
<li key={`${item}-${idx}`} aria-hidden="true" className="flex h-9 w-9 items-center justify-center text-on-surface-variant">
…
</li>
)
)}
<li>
<button
type="button"
aria-disabled={isLast || undefined}
onClick={() => {
if (isLast) return
go(currentPage + 1)
}}
className={`inline-flex h-9 items-center gap-1 rounded-full border border-outline px-3 font-mono text-xs font-medium tracking-[0.05em] uppercase transition ${
isLast
? 'cursor-not-allowed text-on-surface-variant opacity-50'
: 'text-on-surface hover:bg-surface-container'
}`}
>
Next
<svg viewBox="0 0 16 16" aria-hidden="true" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.75">
<path d="m6 3 5 5-5 5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</li>
</ul>
</nav>
)
}
Accessibility pitfalls
AvoidA bare “‹”/“›” glyph as the entire Previous/Next button, with no text and no aria-label.
DoGive the button visible text containing the word “Previous”/“Next” (or an aria-label that does), so the accessible name says what the icon only implies visually.
Avoidaria-current="true" on the current page.
DoUse the literal string aria-current="page" — the value ARIA defines specifically for “this is the current page in a set”.
AvoidUsing the native disabled attribute on Previous/Next, so the button vanishes from the Tab sequence on the first/last page and the control row's shape shifts depending on which page is showing.
DoUse aria-disabled="true" on a still-focusable button, and guard the click/activate handler so it does nothing — the control stays discoverable and the tab order stays constant.
AvoidRendering the current page as a link that points at itself, or leaving it with no aria-current at all so every page number looks the same.
DoRender the current page as plain text, not a control, and mark it aria-current="page".