react nextjs accessibility patterns

Accessible Pagination for React Data Tables

Pagination looks trivial and breaks accessibility in three predictable ways: the control is built from <div>s or bare links with no landmark, the active page is shown only with color, and the table silently swaps its rows so screen reader users never learn the page turned. This guide builds pagination that solves all three: a labeled <nav>, aria-current="page" on the active control, and a polite live region announcing the new visible range after every change.

This is a deep dive under Accessible Data Tables & Grids in React. It assumes you already have an accessible table and focuses entirely on the pagination control and the state changes it triggers.

Mapped WCAG Success Criteria:

  • 4.1.2 Name, Role, Value (Level A)
  • 2.1.1 Keyboard (Level A)
  • 4.1.3 Status Messages (Level AA)
  • 2.4.3 Focus Order (Level A)

A Labeled <nav> for the Pagination Control

Pagination is navigation, so it belongs in a <nav> landmark. Because most pages contain more than one <nav> (primary navigation, breadcrumbs, pagination), give it an aria-label so assistive technology can distinguish them—4.1.2 Name, Role, Value requires the landmark to have a discernible name. Inside, an ordered or unordered list communicates that the page links form a set.

'use client';
import { useRef } from 'react';

type PaginationProps = {
  page: number;
  pageCount: number;
  onChange: (page: number) => void;
};

export function Pagination({ page, pageCount, onChange }: PaginationProps) {
  const pages = Array.from({ length: pageCount }, (_, i) => i + 1);

  return (
    // aria-label distinguishes this nav from other landmarks on the page
    <nav aria-label="Pagination">
      <ul className="pagination-list">
        <li>
          {/* disabled at the lower boundary — exposed as unavailable, not hidden */}
          <button type="button" disabled={page === 1} onClick={() => onChange(page - 1)}>
            <span aria-hidden="true">‹</span>
            <span className="sr-only">Previous page</span>
          </button>
        </li>

        {pages.map((p) => (
          <li key={p}>
            <button
              type="button"
              // aria-current marks the active page for AT and for styling
              aria-current={p === page ? 'page' : undefined}
              aria-label={`Page ${p}`}
              onClick={() => onChange(p)}
            >
              {p}
            </button>
          </li>
        ))}

        <li>
          <button type="button" disabled={page === pageCount} onClick={() => onChange(page + 1)}>
            <span aria-hidden="true">›</span>
            <span className="sr-only">Next page</span>
          </button>
        </li>
      </ul>
    </nav>
  );
}

The prev/next buttons use a visible glyph wrapped in aria-hidden="true" plus an sr-only text label, so sighted users see a chevron and screen reader users hear "Previous page" rather than an ambiguous "‹".


aria-current="page" on the Active Page

The active page must be conveyed to assistive technology, not just rendered in a different color—relying on color alone is both a 4.1.2 failure and a contrast concern. aria-current="page" is the correct mechanism: it marks exactly one element in a set as the current item, and screen readers announce it as "current page."

The pattern enforces the single-active-item rule automatically because aria-current is derived from p === page—only one button can match. Style the current page from the same attribute so the visual and programmatic states never drift apart:

.pagination-list [aria-current='page'] {
  font-weight: 700;
  /* Pair the visual cue with the attribute so they can never disagree */
  outline: 2px solid var(--primary-strong);
}

Driving the highlight off [aria-current='page'] rather than a separate .active class means there is no second source of truth to forget to update.


Accessible Prev/Next With a Disabled State

At the first page, "Previous" has nowhere to go; at the last, "Next" does. Communicate that boundary with the native disabled attribute on the <button>. A disabled native button is removed from the tab order and announced as unavailable, which is the behavior keyboard and screen reader users expect.

Avoid two common anti-patterns. First, do not hide the boundary control entirely—removing "Previous" on page one shifts the layout and surprises users who expect a stable control set. Second, do not fake disabling with aria-disabled="true" while leaving the button clickable unless you also intercept and ignore activation; a control that announces "dimmed" but still fires is worse than either honest state. The native disabled attribute gives you the correct behavior with no extra code.


Announcing the Visible Row Range

This is the piece most pagination implementations miss. When a user clicks "Next," the table's rows swap, but nothing about that change is announced—focus typically remains on the page button, so a screen reader user has no signal that the data updated. A polite live region announcing the new range is the direct remedy and a model 4.1.3 Status Messages case: it reports a state change without moving focus or interrupting speech.

export function PaginatedTable({ rows, pageSize = 20 }: { rows: Row[]; pageSize?: number }) {
  const [page, setPage] = useState(1);
  const pageCount = Math.ceil(rows.length / pageSize);

  const start = (page - 1) * pageSize;
  const visible = rows.slice(start, start + pageSize);
  const from = rows.length === 0 ? 0 : start + 1;
  const to = start + visible.length;

  return (
    <section aria-labelledby="results-heading">
      <h2 id="results-heading">Search results</h2>

      <table>
        <caption>Results, page {page} of {pageCount}</caption>
        {/* …thead and tbody render `visible`… */}
      </table>

      <Pagination page={page} pageCount={pageCount} onChange={setPage} />

      {/* Polite status: announced after each page change, no focus move */}
      <p role="status" aria-live="polite" className="sr-only">
        Showing {from}–{to} of {rows.length} results
      </p>
    </section>
  );
}

Two details make the announcement reliable. Render the role="status" region on initial mount (empty), not conditionally, so it exists in the accessibility tree before its first update—live regions added at the same time as their content are frequently missed. And keep the wording stable ("Showing X–Y of N results") so repeated page turns produce a predictable, scannable message rather than a different sentence each time.


Keyboard Focus Handling After a Page Change

After a page change, where focus lands determines whether keyboard users can continue working or get stranded. There are two sound strategies; pick one and apply it consistently to honor 2.4.3 Focus Order:

  • Keep focus on the activated control. When the user clicks page "3," leave focus on the page-3 button (now marked aria-current). This is the least disruptive choice for number buttons and works well when the live region announces the new range.
  • Move focus to the table or its heading. For prev/next—or when the boundary button you were on becomes disabled and thus loses focus—programmatically move focus to the results heading or the table's container (with tabIndex={-1}) so the user lands on the refreshed data rather than being dumped to the top of the document.
function handleChange(next: number) {
  setPage(next);
  // If the control you used just became disabled, focus the results region
  if (next === 1 || next === pageCount) {
    requestAnimationFrame(() => headingRef.current?.focus());
  }
}

The failure to avoid is silent focus loss: when a button becomes disabled after you activate it, the browser drops focus to <body>, sending keyboard users back to the top of the page. Detect that case and redirect focus deliberately.


How to Verify

  • Automated (axe-core / jest-axe): Assert no violations. axe verifies the <nav> has an accessible name and flags an invalid aria-current value, but it cannot confirm the range announcement fires—test that manually or by asserting the live region's text content after a simulated click.
  • Keyboard only: Tab into the pagination nav. Confirm every page button and prev/next is reachable, that disabled boundary buttons are skipped, and that Enter/Space activate a page. After changing pages, confirm focus is somewhere sensible—on the activated button or the results region—never lost to <body>.
  • Screen reader (NVDA + Firefox, VoiceOver + Safari): Confirm the landmark is announced as "Pagination navigation," that the active page is read as "current page," and that after each page change the polite region speaks "Showing X–Y of N results" without you moving focus.
  • Boundary check: Navigate to the first and last pages and confirm the appropriate prev/next button is announced as unavailable and is not focusable.

Testing Hook: In a component test, simulate clicking "Next," then assert that exactly one button has aria-current="page", the previously disabled state updated, and the role="status" region's text content reads the expected "Showing X–Y of N results."


Common a11y Mistakes

  • Building pagination from <div>s or unlabeled <nav>s, so it is neither a recognizable landmark nor distinguishable from other navigation.
  • Indicating the active page with color only, failing 4.1.2 and leaving the current page invisible to assistive technology—use aria-current="page".
  • Setting aria-current on more than one control, so the current page is ambiguous.
  • Turning pages with no live region, leaving screen reader users unaware the rows changed because focus stayed on the button.
  • Faking a disabled state with aria-disabled while leaving the button clickable, or hiding boundary controls entirely and shifting the layout.
  • Letting focus drop to <body> when the activated button becomes disabled, sending keyboard users to the top of the page.

Frequently Asked Questions

Why does pagination need to be a <nav> instead of just a list of buttons? Pagination is a navigation mechanism, and wrapping it in a <nav> exposes it as a landmark that assistive technology users can jump to directly. Because a page usually has several <nav> landmarks, give the pagination one an aria-label="Pagination" so it is distinguishable, satisfying 4.1.2 Name, Role, Value.

Is aria-current="page" enough to tell users which page they are on? It is the correct way to expose the active page, and screen readers announce it as "current page" when the user reaches that control. But it does not tell users that the table's rows changed when they navigate. Pair it with a polite live region announcing the new visible range so the data change is also communicated, satisfying 4.1.3 Status Messages.

How should I disable Previous on the first page and Next on the last? Use the native disabled attribute on the <button>. A disabled native button is removed from the tab order and announced as unavailable, which matches user expectations. Avoid hiding the control, which shifts layout, and avoid aria-disabled on a still-clickable button, which announces "dimmed" while remaining operable.

Where should focus go after a user changes pages? Either keep focus on the activated page button or move it to the results heading or table container. Choose one and apply it consistently to honor 2.4.3 Focus Order. The critical thing to avoid is silent focus loss: when the button you activated becomes disabled, the browser drops focus to <body>, so detect that case and redirect focus to the refreshed results.