Skip to content
inklu
reactdata-tableswcagaccessibilityjsx

Accessible Data Tables in React: The Patterns That Actually Pass

Sortable columns, responsive layouts, and complex headers break screen readers in ways axe won't always catch. Here is how to build data tables that pass WCAG 2.2.

By Moe· Co-founder, Design8 min read

A table that reads like a wall of numbers

Open a dashboard in VoiceOver. Tab into the revenue table. Instead of "Region, column 1 of 4," you hear "grid," then a flat stream of cell values with no idea which number belongs to which column. Someone shipped a <div> grid styled to look like a table, and the browser's accessibility tree has no concept of rows or headers. To a sighted user it looks fine. To a screen reader user it is unusable.

Data tables are where a lot of otherwise-careful React apps quietly fail WCAG. The markup looks reasonable, the visual design is clean, and an automated scan comes back mostly green — because the hardest table problems are relationships and state, and those are exactly what a static scan struggles to see. This is the pattern reference I wish more teams had before their audit, not after.

Start with real table semantics, not divs

The single most common table failure we see in JSX is a table built out of divs and CSS Grid. It renders identically and it destroys the semantics. A real <table> gives the browser rows, columns, and header associations for free. A grid of divs gives it nothing.

// Broken: no row, column, or header semantics
<div className="table">
  <div className="row">
    <div className="cell">Region</div>
    <div className="cell">Revenue</div>
  </div>
</div>

// Correct: the browser builds the relationships for you
<table>
  <caption>Q2 revenue by region</caption>
  <thead>
    <tr>
      <th scope="col">Region</th>
      <th scope="col">Revenue</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">West</th>
      <td>$482,900</td>
    </tr>
  </tbody>
</table>

Two details do most of the work here. The scope attribute tells assistive tech whether a <th> labels a column or a row, so a screen reader can announce "West, Revenue, $482,900" as the user moves across a cell. And <caption> gives the table an accessible name announced when focus enters it — far better than a visually adjacent <h3> that has no programmatic link to the table. This is WCAG 2.2 success criterion 1.3.1, Info and Relationships: the structure a sighted user perceives has to exist in the markup too.

If you genuinely cannot use a native <table> — say you are wrapping a virtualized list library that renders divs — you can rebuild the semantics with ARIA role="table", role="row", role="columnheader", and role="cell". But you are now hand-maintaining every relationship the browser would have given you for free, and it is easy to get wrong. Reach for it only when native markup is truly impossible.

Sortable columns are the part everyone gets wrong

Almost every dashboard table sorts. Almost none of them announce sorting to a screen reader. A sighted user sees the little chevron flip; a screen reader user hears nothing change. The fix is aria-sort on the header cell plus a real button inside it.

function SortableHeader({ label, sortKey, activeSort, direction, onSort }) {
  const isActive = activeSort === sortKey
  const ariaSort = isActive
    ? (direction === 'asc' ? 'ascending' : 'descending')
    : 'none'

  return (
    <th scope="col" aria-sort={ariaSort}>
      <button type="button" onClick={() => onSort(sortKey)}>
        {label}
        {isActive && (
          <span aria-hidden="true">{direction === 'asc' ? '↑' : '↓'}</span>
        )}
      </button>
    </th>
  )
}

Three things matter. First, aria-sort lives on the <th>, not the button, and only one header at a time should carry a value other than none. Second, the control is a real <button>, so it is keyboard-focusable and operable with Enter and Space without any custom key handling — that is WCAG 2.1.1, Keyboard. Third, the arrow glyph is decorative and marked aria-hidden so it does not get read as stray text; the actual sort state is carried by aria-sort, which screen readers announce as "sorted ascending" when focus lands on the header.

The subtle trap: if you swap the entire table body on sort, a screen reader user has no idea the order changed unless focus stayed on the header they activated. Keep focus on the button. Do not move it to the table or reset it to the top of the page. When focus stays put, aria-sort updating in place is enough for the change to be announced.

Responsive tables without breaking the grid

The most tempting responsive pattern is also the most destructive: on mobile, display: block every <tr> and <td> so cells stack vertically. It looks great and it flattens the accessibility tree — once the elements are no longer laid out as table cells, some browsers stop exposing the row and column relationships entirely.

WCAG 2.2 does require your table to survive being resized. Success criterion 1.4.10, Reflow, says content has to work at 320 CSS pixels wide without horizontal scrolling on the page as a whole. But a data table is the documented exception: a table can scroll horizontally in its own container because its two-dimensional relationships are the content. So wrap it and let it scroll.

<div role="region" aria-label="Q2 revenue by region" tabIndex={0}>
  <table>{/* ... */}</table>
</div>

The wrapper carries three things worth understanding. role="region" plus aria-label names the scrollable area so a screen reader user knows what they have entered. And tabIndex={0} makes the container itself keyboard-focusable, so someone navigating without a mouse can actually scroll it with arrow keys — a scroll container that only responds to a mouse is a keyboard trap in disguise. If you would rather restructure than scroll, the accessible alternative is to collapse each row into its own definition-list-style card, but that is a genuine layout change, not a CSS override on a <table>.

Complex headers and empty cells

Tables with grouped or multi-level headers — a "Q2" spanning three month columns above it — need explicit associations. scope="colgroup" and scope="rowgroup" handle two-level cases. For anything more tangled, give each header an id and point data cells at them with headers, listing the ids in the order you want them read.

<th id="q2" colSpan={3} scope="colgroup">Q2</th>
{/* ... */}
<td headers="q2 apr">$120,400</td>

It is verbose, and it is the only reliable way to make a screen reader read "Q2, April, $120,400" instead of orphaning the number. Do not reach for headers/id on a simple table — plain scope is cleaner and less error-prone. Save it for the genuinely two-dimensional cases.

One more that trips people up: empty cells. A blank <td> gets skipped or read as silence, and the user cannot tell whether the data is zero, missing, or not applicable. Put a real value in — a zero, a dash with an aria-label, or visually hidden text like "no data." Silence is ambiguous, and ambiguity is a failure.

Why your scanner sees only half of this

Here is the honest part. An automated engine — axe-core, which is what inklu runs under the hood, plus our own rules on top — will reliably catch a <td> used where a <th> belongs, a missing scope, a table with no accessible name, an empty header cell. Those are static structural checks, and you should absolutely run them in CI so they never regress. We wrote about wiring that up in keyboard navigation patterns and it is the same discipline here.

What a static scan cannot confirm is whether aria-sort actually flips when you click, whether focus survives a re-sort, whether your responsive breakpoint quietly stripped the semantics, or whether that dash in an empty cell means zero or missing. Those are behavioral and contextual. No purely automated tool resolves them, and any vendor claiming a scan alone gets you to full compliance is overselling — check their site for current details, but treat "100% automated" with suspicion. The realistic split is that automation catches the structural violations fast and at scale, and a human confirms the interactive and semantic ones. inklu leans into the first half: it scans your HTML, CSS, and JSX against WCAG 2.2, and where a fix is unambiguous — a missing scope, a <div> that should be a <th> — it opens a GitHub pull request with the corrected markup for you to review and merge. You stay in control of the diff; nothing lands on your default branch without your say-so.

A checklist you can run against your own tables

Before your next audit, walk your tables against this. Use a real <table> with <thead>, <tbody>, and a <caption>. Give every header the right scope. On sortable columns, put a real <button> in the header and manage aria-sort with exactly one active header, keeping focus on the button after sort. Wrap wide tables in a focusable, labeled scroll region rather than blocking them into stacked divs. Associate complex headers with scope="colgroup" or headers/id. Never ship a silently empty cell. Then run the whole thing through a screen reader — VoiceOver, NVDA, or JAWS — because that is the test that catches what the linter cannot.

Tables are where accessibility work stops being about adding an alt attribute and starts being about relationships and state over time. Get the semantics right at the markup layer and most of the hard problems disappear before they reach a user.

If you want the structural half of that checklist enforced automatically on every pull request, book a demo at inklu.io and we will scan a real table in your codebase live. Questions first? Email hello@inklu.io, or see what a scan costs on our pricing page.