Skip to content

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

KeyAction
Tab / Shift + Tabmoves between Previous, each visible page number, and Next in visual order — a plain row of buttons, no arrow-key layer to learn
Enter or Spaceon 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.

src/components/library/Pagination.tsx
// 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".