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.
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)
Name
Role
Team
Status
Alice Khan
Engineering Manager
Platform
Active
Ben Rao
Senior Developer
Platform
Active
Carla Mehta
Designer
Design System
On leave
SR announces: “Team members (current quarter), table, 4 columns, 3 rows” → “Alice Khan, Role: Engineering Manager, Team: Platform, Status: Active”.
caption + scope headers + thead/tbody. The baseline every data table needs.
Variant B
Div-grid (anti-pattern)
Critical fix
Live example
Subscription plans
Product
Price
Seats
Renews
Pro plan
$29/mo
5
2026-11-01
Team plan
$79/mo
25
2026-11-01
Enterprise
Custom
Unlimited
2027-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 Mehta
212
$15,800
2026-10-19
Alice Khan
128
$9,450
2026-10-18
Ben Rao
76
$5,120
2026-10-15
Dinesh Pal
54
$3,200
2026-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)
Region
Instances
Compute
Storage
Network
Support
Total
us-east-1
42
$4,120
$860
$320
$500
$5,800
eu-west-2
18
$1,780
$410
$180
$500
$2,870
ap-south-1
12
$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.
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.