Responsive & sortable tables

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.
EN 301 549 9.1.3.1 / 9.1.4.10 / 9.2.1.1 / 9.4.1.2 (incorporates WCAG) 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.10 AA 2.1.1 A EN 301 549 Section 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).

bad-overflow.html
<div style="overflow-x: auto">
  <table>
    <!-- many wide columns -->
  </table>
</div>

Good

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

  1. Wrap the table in a scroll container with overflow-x: auto.
  2. Add role="region" and an aria-label (often the same text as the table’s <caption>) so the region is named.
  3. 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.
  4. Ensure the focused region has a visible focus indicator (1.4.11 / 2.4.7).
  5. 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.2 A EN 301 549 Section 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.

good-sort.html
<th scope="col" aria-sort="ascending">
  <button type="button">
    Date
    <span class="sort-icon" aria-hidden="true"></span>
  </button>
</th>
<th scope="col" aria-sort="none">
  <button type="button">Amount</button>
</th>

Code

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

  1. Make the activator a real <button> inside the <th> so it’s keyboard-operable and has the button role.
  2. Put aria-sort on the <th> element, not on the button, with the value ascending, descending, or none.
  3. Keep a direction value on only one header at a time; set the rest to none (or omit the attribute) on each sort.
  4. Mark the visual arrow aria-hidden="true" — the state comes from aria-sort, so the icon must not be announced twice.
  5. 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.1 A EN 301 549 Section 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).

bad-stack.css
@media (max-width: 30rem) {
  table, thead, tbody, tr, th, td { display: block; }
  thead { display: none; }   /* headers gone — values now unlabelled */
}

Good

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.

good-stack.html
<tr>
  <th scope="row">Invoice 042</th>
  <td data-label="Amount">£1,240</td>
  <td data-label="Status">Paid</td>
  <td data-label="Date">2026-03-14</td>
</tr>

Code

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.

good-stack.css
@media (max-width: 30rem) {
  table, tbody, tr, td, th { display: block; }
  thead { display: none; }
  tr { margin-block-end: 1rem; }
  td::before {
    content: attr(data-label) ": ";
    font-weight: 600;
  }
}

How to fix

  1. Prefer the simplest path: wrap the table in a scrollable region (card 1) and leave the native table semantics intact — no restructuring needed.
  2. If you must stack to cards, give each <td> a data-label holding its column header text.
  3. Print the label beside the value with td::before { content: attr(data-label) } so the pairing stays visible.
  4. Keep a <th scope="row"> as each card’s row header so the row still has a name.
  5. 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.