Accessibility Developer Kit · Guide 19 / 20

Accessible Table

Caption + scope headers (the baseline), the APG sortable pattern (aria-sort on the <th>, button inside, next-direction announced), and a scrollable region for responsive layouts that keeps table semantics — Vanilla and React.

  • WCAG: 1.3.1 · 1.3.2 · 2.1.1 · 4.1.2
  • Level: Intermediate
  • Icons: lucide-react (ArrowUp, ArrowDown, ArrowUpDown)
1

What we are building

Native tables with the correct ARIA for sort and responsive scroll — without stripping semantics.

What we are building

A data table only needs a few things to be accessible: <caption> as the FIRST child (not sibling), explicit scope on every <th>, and semantic grouping with <thead> / <tbody>. For sortable columns, follow the APG pattern: aria-sort goes on the <th> and a button inside handles the click.

Avoid the common mobile hack of display: block on table parts — it visibly stacks the data but strips SR table semantics. Use a scrollable region instead.

2

The 4 real-world variants

Switch between the inaccessible version and the accessible fix.

The 4 real-world variants

Variant A

Basic data table

Preferred
Live example
Team members (current quarter)
NameRoleTeamStatus
Alice KhanEngineering ManagerPlatformActive
Ben RaoSenior DeveloperPlatformActive
Carla MehtaDesignerDesign SystemOn leave

SR announces: “Team members (current quarter), table, 4 columns, 3 rows” → “Alice Khan, Role: Engineering Manager, Team: Platform, Status: Active”.

HTML · Accessible
<!-- Semantic table: caption FIRST child, scope on headers -->
<table>
  <caption>Team members (current quarter)</caption>
  <thead>
    <tr>
      <th scope="col">Name</th>
      <th scope="col">Role</th>
      <th scope="col">Team</th>
      <th scope="col">Status</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Alice Khan</th>
      <td>Engineering Manager</td>
      <td>Platform</td>
      <td>Active</td>
    </tr>
    <tr>
      <th scope="row">Ben Rao</th>
      <td>Senior Developer</td>
      <td>Platform</td>
      <td>Active</td>
    </tr>
  </tbody>
</table>

caption + scope headers + thead/tbody. The baseline every data table needs.

Variant B

Div-grid (anti-pattern)

Critical fix
Live example
Subscription plans
ProductPriceSeatsRenews
Pro plan$29/mo52026-11-01
Team plan$79/mo252026-11-01
EnterpriseCustomUnlimited2027-04-01

Div-grid version: SR says “Product. Price. Seats...” as flat text — no row/column structure. Real table version: SR announces the dimensions and reads cells with headers.

HTML · Accessible
<!-- Use a real <table>. Style it with CSS. Keep semantics. -->
<table>
  <caption>Subscription plans</caption>
  <thead>
    <tr>
      <th scope="col">Product</th>
      <th scope="col">Price</th>
      <th scope="col">Seats</th>
      <th scope="col">Renews</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Pro plan</th>
      <td>$29/mo</td>
      <td>5</td>
      <td>2026-11-01</td>
    </tr>
  </tbody>
</table>

<style>
  /* You can still style the table however you like without touching semantics */
  table { width: 100%; border-collapse: collapse; }
  th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #e3e8ee; }
  thead th { background: #f7fafc; }
</style>

CSS Grid of divs removes all table semantics. Use a real <table> — you can still style it however you like.

Variant C

Sortable table (APG)

APG-correct
Live example
Affiliate performance (sortable)
Carla Mehta212$15,8002026-10-19
Alice Khan128$9,4502026-10-18
Ben Rao76$5,1202026-10-15
Dinesh Pal54$3,2002026-10-11

APG pattern: aria-sort on the <th>, a real button inside, SR hears the nextdirection (“Sort by Revenue ascending”).

HTML · Accessible
<!-- APG-correct pattern:
     - aria-sort ON the <th>
     - button INSIDE the th handles click
     - button text announces the NEXT action
     - sort icon is aria-hidden (text carries meaning) -->
<thead>
  <tr>
    <th scope="col" aria-sort="ascending">
      <button type="button" onclick="sortBy('name')">
        Name
        <svg aria-hidden="true"><!-- up arrow --></svg>
        <span class="sr-only"> Sort by Name descending</span>
      </button>
    </th>
    <th scope="col" aria-sort="none">
      <button type="button" onclick="sortBy('email')">
        Email
        <svg aria-hidden="true"><!-- up-down arrow --></svg>
        <span class="sr-only"> Sort by Email ascending</span>
      </button>
    </th>
  </tr>
</thead>

<!-- Rules:
     - Only ONE th at a time has aria-sort="ascending" or "descending"
     - Other sortable columns use aria-sort="none" (or omit the attribute)
     - Reference: https://www.w3.org/WAI/ARIA/apg/patterns/table/examples/sortable-table/ -->

aria-sort on the <th>, button inside, next-direction announced to SR. Reference: w3.org/WAI/ARIA/apg/patterns/table.

Variant D

Responsive (region + scroll)

Preferred
Live example
Monthly server costs (horizontal scroll if narrow)
RegionInstancesComputeStorageNetworkSupportTotal
us-east-142$4,120$860$320$500$5,800
eu-west-218$1,780$410$180$500$2,870
ap-south-112$1,100$220$95$500$1,915

The container has role="region" + aria-labelledby + tabindex=0 — so keyboard users can scroll horizontally with ← →.

HTML · Accessible
<!-- Wrap in a scrollable region. Caption serves as the region's name. -->
<div role="region" aria-labelledby="users-caption" tabindex="0" class="table-scroll">
  <table>
    <caption id="users-caption">Monthly server costs</caption>
    <thead>
      <tr>
        <th scope="col">Region</th>
        <th scope="col">Instances</th>
        <th scope="col">Compute</th>
        <th scope="col">Storage</th>
        <th scope="col">Network</th>
        <th scope="col">Total</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <th scope="row">us-east-1</th>
        <td>42</td>
        <td>$4,120</td>
        <td>$860</td>
        <td>$320</td>
        <td>$5,800</td>
      </tr>
    </tbody>
  </table>
</div>

<style>
  .table-scroll { overflow-x: auto; }
  .table-scroll:focus-visible {
    outline: 3px solid #1c6cbf; outline-offset: 2px;
  }
</style>

<!-- Important:
     - region MUST have an accessible name — without one, SRs drop role=region
     - tabindex=0 lets keyboard users scroll horizontally
     - Keep display:block hacks for last resort; semantics first -->

Wrap in role=region + aria-labelledby + tabindex=0. Keyboard users can scroll horizontally. NEVER display:block on tr/td — strips semantics.

3

Flagship implementation

A reusable AccessibleTable that handles sortable + scrollable + semantic headers.

Flagship implementation

AccessibleTable.jsx
import React, { useMemo, useState } from "react";
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
import "./AccessibleTable.css";

/**
 * AccessibleTable — semantic, optionally sortable, optionally scrollable.
 *
 * Props
 *   caption:    string — becomes <caption>; required for a11y
 *   columns:    [{ key, label, type?: "string" | "number", sortable?: true }]
 *   rows:       array of objects keyed by column.key
 *   rowHeader:  key of the column to render as <th scope="row"> (default columns[0])
 *   scrollable: true to wrap in role=region tabindex=0 for horizontal scroll
 */
export default function AccessibleTable({
  caption, columns, rows,
  rowHeader,
  scrollable = false,
  initialSort,                           // { key, dir: "ascending"|"descending" }
}) {
  const headerKey = rowHeader ?? columns[0]?.key;
  const [sort, setSort] = useState(initialSort ?? null);

  const sortedRows = useMemo(() => {
    if (!sort) return rows;
    const col = columns.find((c) => c.key === sort.key);
    if (!col) return rows;
    const sign = sort.dir === "ascending" ? 1 : -1;
    return [...rows].sort((a, b) => {
      if (col.type === "number") return (a[sort.key] - b[sort.key]) * sign;
      return String(a[sort.key]).localeCompare(String(b[sort.key])) * sign;
    });
  }, [sort, rows, columns]);

  function toggleSort(key) {
    setSort((cur) =>
      cur?.key === key
        ? { key, dir: cur.dir === "ascending" ? "descending" : "ascending" }
        : { key, dir: "ascending" }
    );
  }

  function ariaSort(col) {
    if (!col.sortable) return undefined;
    if (sort?.key !== col.key) return "none";
    return sort.dir;
  }

  const captionId = `caption-${React.useId()}`;

  const table = (
    <table className="data-table">
      <caption id={captionId}>{caption}</caption>
      <thead>
        <tr>
          {columns.map((col) => {
            const isActive = sort?.key === col.key;
            const Icon = !isActive
              ? ArrowUpDown
              : sort.dir === "ascending"
              ? ArrowUp
              : ArrowDown;
            return (
              <th
                key={col.key}
                scope="col"
                aria-sort={ariaSort(col)}
              >
                {col.sortable ? (
                  <button type="button" onClick={() => toggleSort(col.key)}>
                    <span>{col.label}</span>
                    <Icon aria-hidden="true" size={14} />
                    <span className="sr-only">
                      {" "}
                      {isActive
                        ? `Sort by ${col.label} ${sort.dir === "ascending" ? "descending" : "ascending"}`
                        : `Sort by ${col.label} ascending`}
                    </span>
                  </button>
                ) : (
                  col.label
                )}
              </th>
            );
          })}
        </tr>
      </thead>
      <tbody>
        {sortedRows.map((row, i) => (
          <tr key={row[headerKey] ?? i}>
            {columns.map((col) =>
              col.key === headerKey ? (
                <th key={col.key} scope="row">{row[col.key]}</th>
              ) : (
                <td key={col.key}>{row[col.key]}</td>
              )
            )}
          </tr>
        ))}
      </tbody>
    </table>
  );

  if (!scrollable) return table;

  return (
    <div
      role="region"
      aria-labelledby={captionId}
      tabIndex={0}
      className="table-scroll"
    >
      {table}
    </div>
  );
}
Usage · AffiliatesPage.jsx
import AccessibleTable from "./AccessibleTable";

export default function AffiliatesPage() {
  const columns = [
    { key: "name",    label: "Partner",  sortable: true },
    { key: "signups", label: "Signups",  type: "number", sortable: true },
    { key: "revenue", label: "Revenue",  type: "number", sortable: true },
    { key: "updated", label: "Updated",  sortable: true },
  ];

  const rows = [
    { name: "Alice Khan",  signups: 128, revenue: 9450,  updated: "2026-10-18" },
    { name: "Ben Rao",     signups: 76,  revenue: 5120,  updated: "2026-10-15" },
    { name: "Carla Mehta", signups: 212, revenue: 15800, updated: "2026-10-19" },
  ];

  return (
    <AccessibleTable
      caption="Affiliate performance"
      columns={columns}
      rows={rows}
      initialSort={{ key: "revenue", dir: "descending" }}
      scrollable
    />
  );
}
4

Keyboard behavior

Native tables + a focusable scroll region give the right keyboard experience.

Keyboard behavior

ActionExpected result
Tab into the scroll regionRegion receives visible focus; keyboard can scroll it
← → (region focused)Scrolls table horizontally
Tab to sortable headerButton inside the th receives focus
Enter / SpaceToggles sort direction; aria-sort flips
5

Screen reader expectations

A properly-coded table is one of the richest SR experiences.

Screen reader expectations

  • Entry: “Team members (current quarter), table, 4 columns, 3 rows”
  • Each data cell: “Alice Khan, Role: Engineering Manager, Team: Platform, Status: Active”
  • Sortable column (idle): “Partner, column header, Sort by Partner ascending, button”
  • Sortable column (sorted asc): “Partner, column header, sorted ascending, Sort by Partner descending, button”
  • Region: “Monthly server costs, region” (so keyboard users know what to scroll)
6

WCAG mapping

Why each rule matters here.

WCAG mapping

Success CriterionWhy it matters
1.3.1 Info and Relationships (A)caption + th scope establish row/column relationships programmatically.
1.3.2 Meaningful Sequence (A)DOM order of rows must match the visual order.
2.1.1 Keyboard (A)Sort buttons AND scroll region must be keyboard-operable.
4.1.2 Name, Role, Value (A)table/caption/th + aria-sort expose the data model to AT.
1.4.11 Non-text Contrast (AA)Zebra striping, header background, and sort icons must have 3:1 contrast.
7

Testing approach

Framework-agnostic patterns. Pick the one that matches your stack.

Testing approach

Pattern: assert table has caption as name → click sort button → assert aria-sort flipped → assert rows actually re-ordered → scrollable region is focusable.

Jest + Testing Library + jest-axe

AccessibleTable.test.jsx
// Jest + @testing-library/react + jest-axe
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe, toHaveNoViolations } from "jest-axe";
import AccessibleTable from "./AccessibleTable";

expect.extend(toHaveNoViolations);

const columns = [
  { key: "name",   label: "Name",   sortable: true },
  { key: "amount", label: "Amount", type: "number", sortable: true },
];
const rows = [
  { name: "Alice", amount: 300 },
  { name: "Ben",   amount: 100 },
];

test("exposes table with its caption as accessible name", () => {
  render(<AccessibleTable caption="Payouts" columns={columns} rows={rows} />);
  expect(screen.getByRole("table", { name: "Payouts" })).toBeInTheDocument();
});

test("column headers have scope and sortable cells carry aria-sort", async () => {
  const user = userEvent.setup();
  render(<AccessibleTable caption="Payouts" columns={columns} rows={rows} />);
  const nameHeader = screen.getByRole("columnheader", { name: /name/i });
  expect(nameHeader).toHaveAttribute("aria-sort", "none");
  await user.click(screen.getByRole("button", { name: /sort by name ascending/i }));
  expect(nameHeader).toHaveAttribute("aria-sort", "ascending");
});

test("has no axe violations", async () => {
  const { container } = render(
    <AccessibleTable caption="Payouts" columns={columns} rows={rows} />
  );
  expect(await axe(container)).toHaveNoViolations();
});

Vitest + Testing Library

AccessibleTable.test.jsx
// Vitest + @testing-library/react
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import AccessibleTable from "./AccessibleTable";

test("sort by number sorts rows numerically", async () => {
  const user = userEvent.setup();
  render(
    <AccessibleTable
      caption="X"
      columns={[
        { key: "name", label: "Name" },
        { key: "n",    label: "N", type: "number", sortable: true },
      ]}
      rows={[{ name: "A", n: 2 }, { name: "B", n: 10 }, { name: "C", n: 3 }]}
    />
  );
  await user.click(screen.getByRole("button", { name: /sort by n ascending/i }));
  const cells = screen.getAllByRole("cell").map(c => c.textContent);
  expect(cells).toEqual(["2", "3", "10"]);
});

Playwright + @axe-core/playwright

table.spec.js
// Playwright + @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("table page has no serious a11y violations", async ({ page }) => {
  await page.goto("/en/accessible-table");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag22aa"])
    .analyze();
  const serious = results.violations.filter(
    (v) => v.impact === "critical" || v.impact === "serious"
  );
  expect(serious).toEqual([]);
});

test("scrollable region is keyboard-focusable", async ({ page }) => {
  await page.goto("/en/accessible-table");
  const region = page.getByRole("region", { name: /monthly server costs/i });
  await region.focus();
  await expect(region).toBeFocused();
});
8

Manual QA checklist

Tick each item before shipping.

Manual QA checklist

  • Real <table> element (not divs)
  • <caption> is the FIRST child of the table
  • Every <th> has an explicit scope="col" or scope="row"
  • Row-identifier column uses <th scope="row">
  • Sortable columns use aria-sort on the <th> (not on button)
  • Only ONE column has aria-sort ascending/descending at a time
  • Sort trigger is a real <button> inside the th
  • Button label mentions the NEXT direction (sr-only text is fine)
  • Wide tables wrapped in role="region" aria-labelledby tabindex="0"
  • Zero critical / serious axe violations
9

Common mistakes

The table bugs we see on nearly every audit.

Common mistakes

  • Divs via CSS Grid instead of <table>. No semantics, no SR table shortcuts.
  • <th> without scope. SRs guess wrong; row headers often lost.
  • aria-sort on the button. It belongs on the <th>.
  • Sort icon with no SR text. SR users have no idea what pressing does.
  • display: block on tr/td for mobile. Strips table semantics; use a scroll region instead.
  • Caption placed outside the table. Must be the FIRST child inside <table>.
10

Download & next

Grab the code bundle, or jump to the final guide.

Download and next

© 2026 Aria Probe. All rights reserved
Powered by techtipsall Logo