A data table earns its keep by letting people compare values across rows and columns.
That only works while three relationships stay intact: every cell must stay tied to its
row and column header, the user must be able to reach every cell no matter how
narrow the screen, and — if the table can be sorted — the current sort order has to be
perceivable, not just animated. Real tables break on small screens precisely where those
relationships are fragile: a wide grid runs off the edge with no way to scroll it by
keyboard, a sortable header changes order silently, or a “card” layout stacks cells and
quietly drops the header that gave each value its meaning.
This lesson works through the three defects behind most table failures. None of the
fixes need a table library — they come from a correctly marked-up <table>,
one ARIA attribute on the sortable header, and a stacking technique that keeps the
header–value pairing instead of throwing it away.
What you’ll learn
How to wrap an overflowing table in a focusable, keyboard-scrollable
region so no cell is stranded off-screen; how to expose the current sort order on a
sortable column header with aria-sort so it’s announced, not just shown;
and how to build a mobile “card” stack that keeps every value labelled by its column
header so the header-to-value association survives the reflow.
Standards this lesson maps to
Standard
Criterion
Level
What it requires
WCAG 2.2
1.3.1 Info and Relationships
A
Row and column header relationships are conveyed in the markup, so each cell keeps its meaning even when the layout reflows.
WCAG 2.2
1.4.10 Reflow
AA
Content reflows to a 320 px-wide viewport without loss of information; two-dimensional data may scroll in one direction.
WCAG 2.2
2.1.1 Keyboard
A
All functionality — including scrolling an overflow region — is operable from a keyboard.
WCAG 2.2
4.1.2 Name, Role, Value
A
A sortable control exposes its name, role, and current state (the sort direction) to assistive technology.
European harmonised standard; references the WCAG A/AA set including relationships, reflow, keyboard, and name/role/value.
Section 508
502.3 / 504 (incorporates WCAG A & AA)
—
US federal ICT must meet WCAG 2.0 Level A and AA, including table relationships and keyboard operation.
ADA Title II
WCAG 2.1 AA (DOJ rule)
AA
US state/local government web content must conform to WCAG 2.1 AA, which includes Reflow and Name, Role, Value.
The three problems we’ll fix
Each card below isolates one common table defect. For every issue you get a
plain-language statement of the problem, a Bad example (shown as
escaped, non-running code so it can’t harm this page), a Good example,
the copyable Code, and an ordered fix.
Wide table overflows with no keyboard-scrollable region
WCAG 2.2 · 1.4.10AA2.1.1AEN 301 549Section 508
On a narrow screen a wide table is wider than the
viewport. Reflow (1.4.10) allows two-dimensional data like a table to scroll in one
direction, so horizontal scrolling is fine — but only if the user can actually do
it. A bare overflow:auto wrapper scrolls with a mouse or a touch swipe,
yet a keyboard user can’t reach it: a plain <div> isn’t focusable,
so the arrow keys never get the chance to scroll it and the off-screen columns are
stranded (2.1.1). The fix is to make the scroll container focusable and give it a
name, so it lands in the Tab order and a screen reader announces it as a region.
Bad
The wrapper scrolls for a mouse, but it has no tabindex, so it
never receives keyboard focus and its hidden columns can’t be reached by arrow
key (2.1.1).
The wrapper is a named region in the Tab order:
role="region", an aria-label, and
tabindex="0". Now a keyboard user can Tab to it and scroll the
columns with the arrow keys, and a screen reader announces the region’s name.
good-overflow.html
<div class="table-wrap" role="region"
aria-label="Quarterly revenue by region" tabindex="0">
<table>
<caption>Quarterly revenue by region</caption>
<!-- many wide columns -->
</table>
</div>
Code
The region only needs to take focus when it actually overflows — focusing a
region that has no scrollbar adds a pointless tab stop. This small script adds
tabindex="0" only when the content is wider than the box, and
removes it otherwise. Style the focus ring so the region’s focus is visible.
table-scroll.js
function syncScrollFocus(region) {
const overflows = region.scrollWidth > region.clientWidth;
if (overflows) {
region.setAttribute('tabindex', '0');
} else {
region.removeAttribute('tabindex');
}
}
const region = document.querySelector('.table-wrap');
syncScrollFocus(region);
new ResizeObserver(() => syncScrollFocus(region)).observe(region);
How to fix
Wrap the table in a scroll container with overflow-x: auto.
Add role="region" and an aria-label (often the same
text as the table’s <caption>) so the region is named.
Give the container tabindex="0" so it joins the Tab order and
can be scrolled with the arrow keys — ideally only when it actually
overflows.
Ensure the focused region has a visible focus indicator (1.4.11 / 2.4.7).
Test with the keyboard only: Tab to the region, then use Left/Right arrows to
confirm every column can be reached.
Sortable column header gives no sort state
WCAG 2.2 · 4.1.2AEN 301 549Section 508
A clickable column header that re-orders the table is a
control, so Name, Role, Value (4.1.2) applies: it must expose its name, its role,
and — crucially — its current state. The usual implementation shows the
sort direction with a little up or down arrow drawn in CSS and nothing else. A
sighted mouse user sees it; a screen reader user hears only “Date, column header”
with no hint that the table is sorted by date, ascending. The state lives entirely
in the pixels. aria-sort puts it back in the markup: set it to
ascending, descending, or none on the
one header that is currently controlling the order.
Bad
The header sorts on click, but the direction is conveyed only by a CSS arrow.
There’s no button role and no aria-sort, so the current order is
invisible to assistive technology (4.1.2).
bad-sort.html
<th scope="col" class="is-sorted-asc" onclick="sortByDate()">
Date
</th>
<!-- the ▲ is a CSS ::after arrow; nothing is in the a11y tree -->
Good
The sort state lives on the <th> as
aria-sort="ascending", and the activator is a real
<button> so it’s focusable, has a role, and works with the
keyboard. Only the active column carries a direction value.
On each activation, clear aria-sort on every column, then set the
new direction on the clicked one. Setting it to none elsewhere (or
removing it) keeps exactly one active sort. Use a live region only if you want
the result announced explicitly.
sort.js
function setSort(table, activeTh, direction) {
// direction is 'ascending' or 'descending'
table.querySelectorAll('th[aria-sort]').forEach((th) => {
th.setAttribute('aria-sort', 'none');
});
activeTh.setAttribute('aria-sort', direction);
// ...reorder the <tbody> rows accordingly...
}
How to fix
Make the activator a real <button> inside the
<th> so it’s keyboard-operable and has the button role.
Put aria-sort on the <th> element, not on the
button, with the value ascending, descending, or
none.
Keep a direction value on only one header at a time; set the rest to
none (or omit the attribute) on each sort.
Mark the visual arrow aria-hidden="true" — the state comes from
aria-sort, so the icon must not be announced twice.
Verify in the accessibility tree that the active header reads, e.g.,
“Date, ascending, column header, button”.
“Card” stacking on mobile loses the header-to-value association
WCAG 2.2 · 1.3.1AEN 301 549Section 508
A popular mobile pattern turns each row into a stacked
“card” by setting the table elements to display: block. Visually it’s
tidy, but it’s also destructive: once a <tr> and its cells stop
being table rows and cells, the browser no longer maps each value to its column
header. A user then meets a column of bare values — £1,240,
Paid, 2026-03-14 — with nothing saying which is the amount,
the status, or the date. The header-to-value relationship that 1.3.1 requires has
been thrown away by the very CSS that made it look neat. Keep the association by
printing each cell’s header next to its value.
Bad
display: block on the table parts collapses the grid into a
single column. The header row is hidden, so every value is now unlabelled and
the relationship is lost (1.3.1).
Each cell keeps its label by carrying the header text in a
data-label attribute, printed before the value with CSS generated
content. The pairing stays visible, and because the table elements still
describe rows and cells the data reads in order.
Print the data-label before each value on narrow screens. The
label is decorative repetition of the visible header, so it’s fine as CSS
content. The safest choice of all is often to not restructure: let the
table scroll (card 1) and keep the native header semantics untouched.
Prefer the simplest path: wrap the table in a scrollable region (card 1) and
leave the native table semantics intact — no restructuring needed.
If you must stack to cards, give each <td> a
data-label holding its column header text.
Print the label beside the value with td::before { content:
attr(data-label) } so the pairing stays visible.
Keep a <th scope="row"> as each card’s row header so the
row still has a name.
Test the stacked view with a screen reader: each value should be reachable
with its header, not as a bare orphaned number.
Recap
Wrap a wide table in a focusable region — role="region",
an aria-label, and tabindex="0" — so the overflow can be
scrolled by keyboard and the region is announced (1.4.10, 2.1.1).
Make sortable headers real <button>s and set
aria-sort to ascending, descending, or
none on the active column header so the sort state is exposed, not
just drawn (4.1.2).
When stacking to cards on mobile, keep each value labelled by its column header
— repeat the header text or use a real two-column row layout — so the
header-to-value association survives the reflow (1.3.1).
The same fixes satisfy WCAG, EN 301 549, Section 508, and ADA
Title II at once — keep the relationships in the markup and you meet them all.