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, and it pairs closely with Building a Sortable Accessible Data Table in React: sorting and paging both mutate the rows under a screen reader user without moving focus, so both lean on the same announcement discipline. This guide 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)

Prerequisites

This pattern builds on top of an already-accessible table, so make sure these are in place first:

  • A native <table> with a <caption>, <thead>, and <th scope> headers—the semantic foundation covered in Accessible Data Tables & Grids in React. Pagination controls the slice of rows shown; it does not change how the table itself is marked up.
  • Row data held in React state (useState here, but the pattern is identical with TanStack Table, server-driven data, or an infinite query), so changing the page is a state update rather than a full navigation.
  • A polite live region available in the DOM. We render one inline below, but a shared announcer such as the one in Dynamic Content & State Announcements works just as well and is the better choice once several components need to speak.

If your table is virtualized rather than paged, the announcement problem is similar but the semantics differ—see Virtualizing Long Lists Accessibly in React for keeping aria-rowcount honest across recycled rows.


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.


What a page change has to communicate Three things every page change must convey. The new range and total, announced politely, so the user knows where they are in the dataset. The current page marked with aria-current so the pagination control itself reflects position. Focus placed deliberately, on the table caption or the pagination control, never left on a button that has been disabled. The new range and total "Showing 21 to 40 of 218 rows", announced politely, once The current page aria-current="page" on the active number in the pagination nav Focus, placed deliberately never left on a Next button that has just become disabled

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. This is the same technique used to announce the new order in Building a Sortable Accessible Data Table in React—any silent mutation of the rendered rows needs one.

What happens when a screen reader user turns the page Activating the Next button increments React page state, which re-renders the table body with the next slice of rows, and a polite role="status" live region announces "Showing 21 to 40 of 87 results" to the screen reader. Focus never leaves the Next button throughout. 1. User activates ‹Next› 2. setPage(page + 1) 3. <tbody> swaps to rows 21–40 4. role="status" (polite) announces "Showing 21–40 of 87 results" Focus stays on ‹Next› Heard by the screen reader, focus not moved
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.


Where focus should land after paging Three candidate targets ranked. The pagination control the user activated is the best target when it still exists, since it keeps them in the control they were operating. The table caption, made focusable, is the right fallback when the button becomes disabled at the last page. Leaving focus on a removed or disabled button is the failure case. The control just activated best, when it still exists — the user stays where they were The table caption, tabindex -1 the fallback when Next becomes disabled at the last page A removed or disabled button the failure case: focus falls to <body> and nothing reports it

Keyboard Focus Handling After a Page Change

After a page change, where focus lands determines whether keyboard users can continue working or get stranded. This is the same class of problem as restoring focus after a navigation, covered in depth in Focus Management Strategies for SPAs. 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.

Conclusion

Accessible pagination comes down to four decisions, each mapped to a success criterion: wrap the control in a labeled <nav> so it is a distinguishable landmark (4.1.2), mark the active page with aria-current="page" and drive its styling from that same attribute so the visual and programmatic states can never disagree, use the native disabled attribute at the boundaries so prev/next are honestly unavailable rather than dimmed-but-clickable (2.1.1), and—the step most implementations skip—announce the new visible range through a polite live region (4.1.3) while landing focus somewhere sensible (2.4.3). Get those four right and a screen reader or keyboard user can page through thousands of rows with the same confidence a sighted mouse user has. Wire the same live-region discipline into every other control that mutates the table's rows, and the whole grid stays coherent no matter how the data is sliced.


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.