react nextjs accessibility patterns
Accessible Data Tables & Grids in React
Data tables are where accessibility most often quietly breaks in React applications. A table that looks correct on screen can be unintelligible to a screen reader user the moment a <div> grid replaces native markup, a header loses its association with its column, or a sort control swaps a row order with no announcement. Tabular data carries meaning in its structure—the relationship between a cell and its row and column headers is the information. When that structure is destroyed, the data is gone for anyone who cannot see the visual grid.
This guide sits within the broader React & Next.js Accessibility Patterns and shows how to keep tabular semantics intact through the features real applications demand: sorting, selection, pagination, and virtualization. It pairs directly with Dynamic Content & State Announcements for live-region behavior and with Accessible Component Libraries in React when you evaluate prebuilt grids. Deep dives live in the three child guides: building a sortable accessible data table, accessible pagination, and virtualizing long lists accessibly.
Mapped WCAG Success Criteria:
1.3.1 Info and Relationships(Level A)4.1.2 Name, Role, Value(Level A)2.1.1 Keyboard(Level A)4.1.3 Status Messages(Level AA)
Core Implementation Focus:
- Preserving table semantics across CSS layout and component abstraction
- Header-to-cell association with
<caption>,scope, andheaders - Sortable columns that announce their new state
- Selection, pagination, and virtualization without dropping the accessibility tree
Anatomy of an Accessible Table
Before any framework code, it helps to see exactly what a screen reader derives from semantic table markup. Each structural element maps to something the assistive technology announces as the user navigates cell by cell.
The takeaway: the screen reader's announcement is reconstructed entirely from <caption>, <th scope>, and the row/column geometry of native table elements. Everything in the rest of this guide protects those relationships.
Semantic <table> vs <div> Grids
The single most common table accessibility failure is rebuilding a table out of <div>s for styling flexibility. A <div className="row"> containing <div className="cell"> elements renders identically on screen but exposes nothing to assistive technology: no table role, no row or column count, no header associations. Screen reader table-navigation commands (jump to next cell, read column header) stop working entirely because there is no table to navigate.
Native elements give you the relationships defined by 1.3.1 Info and Relationships for free. Reach for <table>, <thead>, <tbody>, <tr>, <th>, and <td> first.
// A native semantic table — every relationship is implicit and free.
function RevenueTable({ rows }: { rows: RegionRow[] }) {
return (
<table>
{/* caption names the table for AT and is the first thing announced */}
<caption>Q3 revenue by region</caption>
<thead>
<tr>
{/* scope="col" associates each header with its entire column */}
<th scope="col">Region</th>
<th scope="col">Revenue</th>
<th scope="col">Growth</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
{/* scope="row" makes the region label a header for the data cells */}
<th scope="row">{row.region}</th>
<td>{row.revenue}</td>
<td>{row.growth}</td>
</tr>
))}
</tbody>
</table>
);
}
A subtle trap: even a native <table> can lose its semantics through CSS. Setting display: flex or display: grid on a <table>, <tr>, or <td> can strip the implicit table roles in some browsers, because the computed display value drives the accessibility role. If you need CSS Grid layout for a complex responsive table, either keep the table semantics and lay out with grid-template-columns on the <table> only after testing in a screen reader, or restore roles explicitly with role="table", role="row", role="columnheader", and role="cell". Prefer not breaking it in the first place.
Testing Hook: Run the table through your screen reader's table-navigation commands (NVDA:
Ctrl+Alt+Arrow; VoiceOver:Ctrl+Option+Arrow). If those commands do nothing, the table role has been lost—almost always to adisplayoverride or a<div>grid.
Caption, scope, and Header Associations
1.3.1 Info and Relationships is satisfied when every data cell is programmatically associated with the headers that describe it. For the overwhelming majority of tables, scope does this:
<th scope="col">associates the header with all cells beneath it in the column.<th scope="row">associates the header with all cells in its row—ideal for the first column that names each record.
For irregular tables with merged cells or multiple header layers, scope is not expressive enough. Use explicit id/headers wiring, where each <td> lists the IDs of every header that applies to it.
// Complex header: explicit id/headers wiring for a cell governed by two headers.
<table>
<caption>Quarterly headcount by department and location</caption>
<thead>
<tr>
<th scope="col" id="dept">Department</th>
<th scope="col" id="ny">New York</th>
<th scope="col" id="ldn">London</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row" id="eng">Engineering</th>
{/* headers ties this cell to both its column and row headers */}
<td headers="eng ny">48</td>
<td headers="eng ldn">31</td>
</tr>
</tbody>
</table>
Always provide a <caption>—it is the accessible name of the table and the first thing announced. If the visual design has no room for a caption, keep the element and visually hide it with an sr-only utility rather than dropping it. Never substitute a nearby <h2> for a real caption; the heading is not programmatically tied to the table.
Testing Hook: axe-core flags missing captions and orphaned data cells, but it cannot verify that the associations are correct. Manually arrow into a data cell and confirm the screen reader speaks the intended column and row header before the value.
Sortable Columns With aria-sort
Sortable headers are where keyboard and screen reader support most often regress. The accessible pattern has three parts, each tied to a success criterion:
- The sort control is a real
<button>inside the<th>, so it is keyboard-focusable and operable per2.1.1 Keyboard. - The active column's
<th>carriesaria-sort="ascending" | "descending"; all other sortable headers carryaria-sort="none". This satisfies4.1.2 Name, Role, Valueby exposing the current sort state. - The sort change is announced through a polite live region per
4.1.3 Status Messages, because reordering rows is a visual change a screen reader user would otherwise miss.
'use client';
import { useState } from 'react';
type SortDir = 'ascending' | 'descending' | 'none';
function SortableHeader({
label, columnKey, sort, onSort,
}: {
label: string;
columnKey: string;
sort: { key: string; dir: SortDir };
onSort: (key: string) => void;
}) {
const isActive = sort.key === columnKey;
// aria-sort lives on the th, not the button — AT reads it as a column property
const ariaSort: SortDir = isActive ? sort.dir : 'none';
return (
<th scope="col" aria-sort={ariaSort}>
<button type="button" onClick={() => onSort(columnKey)}>
{label}
{/* decorative arrow is hidden; aria-sort already conveys direction */}
<span aria-hidden="true">
{isActive ? (sort.dir === 'ascending' ? ' ▲' : ' ▼') : ''}
</span>
</button>
</th>
);
}
The sortable table deep dive walks through the full component, including the announcement string ("Table sorted by Revenue, descending") and the live region wiring.
Testing Hook: Tab to a header, press Enter/Space, and confirm the screen reader announces both the new direction (from
aria-sort) and the polite message. Verifyaria-sortis present on exactly one header at a time.
Row Selection Patterns
Selectable rows—for bulk actions—need each control to expose its name and checked state. Use a native <input type="checkbox"> per row, labeled by the row's header, plus a "select all" checkbox in the column header that reflects an indeterminate state when only some rows are selected.
function SelectableRows({ rows, selected, toggle }: SelectableProps) {
return (
<tbody>
{rows.map((row) => (
<tr key={row.id} aria-selected={selected.has(row.id)}>
<td>
<input
type="checkbox"
checked={selected.has(row.id)}
onChange={() => toggle(row.id)}
// Name the checkbox by the row it controls — never a bare "select"
aria-label={`Select ${row.region}`}
/>
</td>
<th scope="row">{row.region}</th>
<td>{row.revenue}</td>
</tr>
))}
</tbody>
);
}
Two notes. First, set the "select all" checkbox's indeterminate DOM property via a ref when the selection is partial—it is not an HTML attribute. Second, announce the running selection count ("3 rows selected") through a polite live region so the state change is perceivable without sight, exactly as covered in Dynamic Content & State Announcements.
Testing Hook: Navigate with the keyboard alone and confirm every checkbox is reachable and clearly named. Verify the select-all checkbox announces "partially checked" / "mixed" when some but not all rows are selected.
When to Use role="grid" / treegrid — and When Not To
This is the most over-reached-for pattern in table accessibility. role="grid" is not a styling hint or a generic "data table" role. It is an interaction model: a grid is a widget where arrow keys move a single focus point between cells, like a spreadsheet, and the entire grid is a single tab stop. Adopting it commits you to implementing full roving-tabindex keyboard navigation, focus management, and aria-activedescendant or per-cell tabindex orchestration.
Most "data tables"—even ones with sorting, selection, and pagination—are not grids. They are static tabular content with a few interactive controls (a sort button, a checkbox) that are individually in the tab order. For these, a plain semantic <table> is correct, more robust, and far less code. Reaching for role="grid" here usually makes accessibility worse, because half-implemented grid keyboard support is more confusing than no grid behavior at all.
Use role="grid" only when the cells themselves are the interactive surface and users expect spreadsheet-style arrow navigation: editable data grids, calendar date pickers, and the like. Use role="treegrid" only when rows are additionally expandable/collapsible in a hierarchy. If you adopt a grid, the virtualization guide covers the aria-rowcount and aria-rowindex attributes that grids require once the DOM holds only a slice of rows.
Testing Hook: Ask yourself: "Do users navigate this with arrow keys between cells?" If no, do not use
role="grid". If yes, verify a single tab stop enters the grid and arrow keys move focus across cells.
Pagination and Announcing Result Counts
Paginated tables present two distinct accessibility problems. The first is the pagination control itself: it should be a <nav aria-label="Pagination"> whose current page carries aria-current="page", with prev/next controls that expose a disabled state at the boundaries. The second is announcing what changed after a page click—the visible row range and total—so a screen reader user knows the page turned.
<nav aria-label="Pagination">
<ul>
<li>
<button type="button" disabled={page === 1} onClick={prev}>
Previous
</button>
</li>
{pages.map((p) => (
<li key={p}>
<button
type="button"
// aria-current marks the active page for AT and styling
aria-current={p === page ? 'page' : undefined}
onClick={() => goTo(p)}
>
{p}
</button>
</li>
))}
<li>
<button type="button" disabled={page === lastPage} onClick={next}>
Next
</button>
</li>
</ul>
</nav>
{/* Polite status: announces the new slice after each page change */}
<p role="status" aria-live="polite" className="sr-only">
Showing {start}–{end} of {total} results
</p>
The "showing X–Y of N" message is a textbook 4.1.3 Status Messages use case: it conveys a state change without moving focus or interrupting the user. The pagination deep dive covers focus handling after a page change—where to send focus so keyboard users land on the refreshed data rather than the bottom of the page.
Testing Hook: Click a page, then confirm the screen reader announces the new range without you moving focus. Tab through the nav and verify
aria-current="page"lands on exactly the active page and disabled prev/next are skipped or announced as unavailable.
Common a11y Mistakes
- Rebuilding tables from
<div>s, destroying every header-to-cell relationship and breaking screen reader table navigation. - Breaking native table roles with
display: flexordisplay: gridon table elements without restoring roles or re-testing. - Omitting
<caption>or substituting an unassociated heading as the table's name. - Making sort headers clickable
<th>s instead of<button>s inside them, leaving the control unreachable by keyboard. - Setting
aria-sorton every sortable header at once (or never updating it), so AT cannot tell which column is sorted. - Reaching for
role="grid"on static tables, committing to spreadsheet keyboard navigation that is then only half-implemented. - Reordering rows or turning pages silently, leaving screen reader users unaware the data changed because no live region announced it.
Frequently Asked Questions
When should I use a native <table> versus role="grid" in React?
Use a native <table> for virtually all data display, including tables with sorting, selection, and pagination, because those controls are individually focusable and need no special keyboard model. Use role="grid" only for spreadsheet-like widgets where users navigate between cells with arrow keys and the whole grid is a single tab stop, since that role commits you to implementing roving focus and cell-level keyboard navigation.
Why does my table lose its accessibility when I add CSS Grid or Flexbox?
The accessibility role of an element is derived from its computed display value. Applying display: flex or display: grid to a <table>, <tr>, or <td> can override the implicit table, row, or cell role in some browsers, leaving assistive technology with no table structure to navigate. Either keep table semantics and avoid those overrides, or restore the roles explicitly with role="table", role="row", and role="cell" and re-test in a screen reader.
How do screen reader users know a table column was sorted?
Two mechanisms work together. Set aria-sort="ascending" or "descending" on the active column's <th> so the direction is exposed as a property of the header, and announce the change through a polite aria-live region with a message like "Table sorted by Revenue, descending." The aria-sort value alone is read when the user revisits the header, while the live region informs users who keep their place in the data after sorting.
Do I need a live region for pagination, or is aria-current enough?
You need both. aria-current="page" identifies the active page button within the pagination <nav>, which helps when a user tabs through the control. But it does not tell users that the visible rows changed when they click a page. A polite live region announcing "Showing 21–40 of 312 results" communicates the actual data change, satisfying 4.1.3 Status Messages.
Is it accessible to virtualize a long table with react-window?
Only if you preserve the table's true size in the accessibility tree. Virtualization keeps just a slice of rows in the DOM, so assistive technology would otherwise report a tiny table. Expose the real dimensions with aria-rowcount on the grid and aria-rowindex on each rendered row, and manage focus carefully as rows unmount. If the dataset is small enough to render fully without performance problems, skip virtualization entirely—a plain table is more robust.