Accessibility pattern · Data display

Responsive table

A wide table can scroll inside a labelled, focusable region, collapse each row into a card, or hide its least important columns behind a toggle. Whichever you pick, it stays a table to assistive technology: explicit roles keep the rows, cells and headers when CSS turns them into cards.

WCAG criteria
6
Keyboard rules
3
Checked with
axe, keyboard and the inspector

Live demo

Try it

Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.

Layout on narrow screens

The table keeps its shape and scrolls sideways inside a labelled region you can focus.

Shipments this week
Shipment Destination Service Weight Dispatched Delivery by Cost Status
SH-2041 Kochi Express 2.4 kg 29 Sep 1 Oct ₹480 Delivered
SH-2042 Jaipur Standard 0.8 kg 30 Sep 3 Oct ₹260 Delivered
SH-2043 Guwahati Economy 5.1 kg 1 Oct 8 Oct ₹615 Delayed
SH-2044 Indore Standard 1.2 kg 2 Oct 6 Oct ₹310 In transit
SH-2045 Mysuru Express 3.0 kg 3 Oct 5 Oct ₹395 Out for delivery
SH-2046 Surat Standard 0.5 kg 4 Oct 7 Oct ₹240 Packed

Scroll for more

The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.

Keyboard

Keys it answers to

Every action works without a pointer.

KeyWhat it does
Arrow Left or Arrow RightIn the layout switch, picks the previous or next layout. In the focused scroll region, scrolls the table sideways.
TabMoves to the scroll region while the table overflows, then to Show more columns in the priority layout.
Enter or SpaceOn Show more columns, shows every column or goes back to the priority ones.

Screen readers

What it announces

Written from the roles, names and states in the markup.

WhenExpected announcement
Tab reaches the scroll regionShipments this week, region, Scroll for more
A screen reader's table commands move into a stacked cardDestination, column 2, Kochi
Focus reaches the toggle in the priority layoutShow more columns, toggle button, not pressed
Enter shows every columnShow more columns, toggle button, pressed

These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.

Code

Copy the code

The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.

The markup

<div class="ap-rtable" data-ap-rtable data-layout="scroll">
  <fieldset class="ap-rtable__modes">
    <legend class="ap-rtable__legend">Layout on narrow screens</legend>
    <div class="ap-rtable__seg">
      <label class="ap-rtable__mode"><input type="radio" name="rtable-layout" value="scroll" checked /><span>Scroll</span></label>
      <label class="ap-rtable__mode"><input type="radio" name="rtable-layout" value="stack" /><span>Stack</span></label>
      <label class="ap-rtable__mode"><input type="radio" name="rtable-layout" value="priority" /><span>Priority columns</span></label>
    </div>
  </fieldset>
  <p class="ap-rtable__about" data-ap-about="scroll">The table keeps its shape and scrolls sideways inside a labelled region you can focus.</p>
  <p class="ap-rtable__about" data-ap-about="stack" hidden>Each row becomes a card. Explicit table roles keep its rows, cells and headers for screen readers.</p>
  <p class="ap-rtable__about" data-ap-about="priority" hidden>Lower-priority columns give way as space runs out, and Show more columns brings them back.</p>

  <div class="ap-rtable__card">
    <div class="ap-rtable__frame" data-ap-frame>
      <div class="ap-rtable__scroll" role="region" aria-labelledby="rtable-name" tabindex="0" data-ap-scroll>
        <!-- Explicit roles: the stacked layout changes how table parts display, and some browsers drop table semantics when that happens. -->
        <table class="ap-rtable__table" id="rtable-table" role="table">
          <caption class="ap-rtable__cap"><span class="ap-rtable__name" id="rtable-name">Shipments this week</span></caption>
          <thead role="rowgroup">
            <tr role="row">
              <th role="columnheader" scope="col">Shipment</th>
              <th role="columnheader" scope="col" data-priority="2">Destination</th>
              <th role="columnheader" scope="col" data-priority="3">Service</th>
              <th role="columnheader" scope="col" class="ap-rtable__num" data-priority="3">Weight</th>
              <th role="columnheader" scope="col" data-priority="3">Dispatched</th>
              <th role="columnheader" scope="col">Delivery by</th>
              <th role="columnheader" scope="col" class="ap-rtable__num" data-priority="2">Cost</th>
              <th role="columnheader" scope="col">Status</th>
            </tr>
          </thead>
          <tbody role="rowgroup">
            <tr role="row">
              <th role="rowheader" scope="row">SH-2041</th>
              <td role="cell" data-priority="2"><span class="ap-rtable__label" aria-hidden="true">Destination</span><span class="ap-rtable__value">Kochi</span></td>
              <td role="cell" data-priority="3"><span class="ap-rtable__label" aria-hidden="true">Service</span><span class="ap-rtable__value">Express</span></td>
              <td role="cell" class="ap-rtable__num" data-priority="3"><span class="ap-rtable__label" aria-hidden="true">Weight</span><span class="ap-rtable__value">2.4 kg</span></td>
              <td role="cell" data-priority="3"><span class="ap-rtable__label" aria-hidden="true">Dispatched</span><span class="ap-rtable__value">29 Sep</span></td>
              <td role="cell"><span class="ap-rtable__label" aria-hidden="true">Delivery by</span><span class="ap-rtable__value">1 Oct</span></td>
              <td role="cell" class="ap-rtable__num" data-priority="2"><span class="ap-rtable__label" aria-hidden="true">Cost</span><span class="ap-rtable__value">₹480</span></td>
              <td role="cell"><span class="ap-rtable__label" aria-hidden="true">Status</span><span class="ap-rtable__status ap-rtable__status--done"><svg class="ap-rtable__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="m8.2 12.4 2.6 2.6 5-5.2"/></svg>Delivered</span></td>
            </tr>
            <tr role="row">
              <th role="rowheader" scope="row">SH-2042</th>
              <td role="cell" data-priority="2"><span class="ap-rtable__label" aria-hidden="true">Destination</span><span class="ap-rtable__value">Jaipur</span></td>
              <td role="cell" data-priority="3"><span class="ap-rtable__label" aria-hidden="true">Service</span><span class="ap-rtable__value">Standard</span></td>
              <td role="cell" class="ap-rtable__num" data-priority="3"><span class="ap-rtable__label" aria-hidden="true">Weight</span><span class="ap-rtable__value">0.8 kg</span></td>
              <td role="cell" data-priority="3"><span class="ap-rtable__label" aria-hidden="true">Dispatched</span><span class="ap-rtable__value">30 Sep</span></td>
              <td role="cell"><span class="ap-rtable__label" aria-hidden="true">Delivery by</span><span class="ap-rtable__value">3 Oct</span></td>
              <td role="cell" class="ap-rtable__num" data-priority="2"><span class="ap-rtable__label" aria-hidden="true">Cost</span><span class="ap-rtable__value">₹260</span></td>
              <td role="cell"><span class="ap-rtable__label" aria-hidden="true">Status</span><span class="ap-rtable__status ap-rtable__status--done"><svg class="ap-rtable__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="m8.2 12.4 2.6 2.6 5-5.2"/></svg>Delivered</span></td>
            </tr>
            <tr role="row">
              <th role="rowheader" scope="row">SH-2043</th>
              <td role="cell" data-priority="2"><span class="ap-rtable__label" aria-hidden="true">Destination</span><span class="ap-rtable__value">Guwahati</span></td>
              <td role="cell" data-priority="3"><span class="ap-rtable__label" aria-hidden="true">Service</span><span class="ap-rtable__value">Economy</span></td>
              <td role="cell" class="ap-rtable__num" data-priority="3"><span class="ap-rtable__label" aria-hidden="true">Weight</span><span class="ap-rtable__value">5.1 kg</span></td>
              <td role="cell" data-priority="3"><span class="ap-rtable__label" aria-hidden="true">Dispatched</span><span class="ap-rtable__value">1 Oct</span></td>
              <td role="cell"><span class="ap-rtable__label" aria-hidden="true">Delivery by</span><span class="ap-rtable__value">8 Oct</span></td>
              <td role="cell" class="ap-rtable__num" data-priority="2"><span class="ap-rtable__label" aria-hidden="true">Cost</span><span class="ap-rtable__value">₹615</span></td>
              <td role="cell"><span class="ap-rtable__label" aria-hidden="true">Status</span><span class="ap-rtable__status ap-rtable__status--late"><svg class="ap-rtable__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7v5.5"/><path d="M12 16.2v.1"/></svg>Delayed</span></td>
            </tr>
            <tr role="row">
              <th role="rowheader" scope="row">SH-2044</th>
              <td role="cell" data-priority="2"><span class="ap-rtable__label" aria-hidden="true">Destination</span><span class="ap-rtable__value">Indore</span></td>
              <td role="cell" data-priority="3"><span class="ap-rtable__label" aria-hidden="true">Service</span><span class="ap-rtable__value">Standard</span></td>
              <td role="cell" class="ap-rtable__num" data-priority="3"><span class="ap-rtable__label" aria-hidden="true">Weight</span><span class="ap-rtable__value">1.2 kg</span></td>
              <td role="cell" data-priority="3"><span class="ap-rtable__label" aria-hidden="true">Dispatched</span><span class="ap-rtable__value">2 Oct</span></td>
              <td role="cell"><span class="ap-rtable__label" aria-hidden="true">Delivery by</span><span class="ap-rtable__value">6 Oct</span></td>
              <td role="cell" class="ap-rtable__num" data-priority="2"><span class="ap-rtable__label" aria-hidden="true">Cost</span><span class="ap-rtable__value">₹310</span></td>
              <td role="cell"><span class="ap-rtable__label" aria-hidden="true">Status</span><span class="ap-rtable__status ap-rtable__status--move"><svg class="ap-rtable__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M2.5 6.5h11v9h-11z"/><path d="M13.5 9.5h4l3 3.2v2.8h-7"/><circle cx="6.5" cy="17.5" r="1.8"/><circle cx="16.5" cy="17.5" r="1.8"/></svg>In transit</span></td>
            </tr>
            <tr role="row">
              <th role="rowheader" scope="row">SH-2045</th>
              <td role="cell" data-priority="2"><span class="ap-rtable__label" aria-hidden="true">Destination</span><span class="ap-rtable__value">Mysuru</span></td>
              <td role="cell" data-priority="3"><span class="ap-rtable__label" aria-hidden="true">Service</span><span class="ap-rtable__value">Express</span></td>
              <td role="cell" class="ap-rtable__num" data-priority="3"><span class="ap-rtable__label" aria-hidden="true">Weight</span><span class="ap-rtable__value">3.0 kg</span></td>
              <td role="cell" data-priority="3"><span class="ap-rtable__label" aria-hidden="true">Dispatched</span><span class="ap-rtable__value">3 Oct</span></td>
              <td role="cell"><span class="ap-rtable__label" aria-hidden="true">Delivery by</span><span class="ap-rtable__value">5 Oct</span></td>
              <td role="cell" class="ap-rtable__num" data-priority="2"><span class="ap-rtable__label" aria-hidden="true">Cost</span><span class="ap-rtable__value">₹395</span></td>
              <td role="cell"><span class="ap-rtable__label" aria-hidden="true">Status</span><span class="ap-rtable__status ap-rtable__status--near"><svg class="ap-rtable__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11Z"/><circle cx="12" cy="10" r="2.3"/></svg>Out for delivery</span></td>
            </tr>
            <tr role="row">
              <th role="rowheader" scope="row">SH-2046</th>
              <td role="cell" data-priority="2"><span class="ap-rtable__label" aria-hidden="true">Destination</span><span class="ap-rtable__value">Surat</span></td>
              <td role="cell" data-priority="3"><span class="ap-rtable__label" aria-hidden="true">Service</span><span class="ap-rtable__value">Standard</span></td>
              <td role="cell" class="ap-rtable__num" data-priority="3"><span class="ap-rtable__label" aria-hidden="true">Weight</span><span class="ap-rtable__value">0.5 kg</span></td>
              <td role="cell" data-priority="3"><span class="ap-rtable__label" aria-hidden="true">Dispatched</span><span class="ap-rtable__value">4 Oct</span></td>
              <td role="cell"><span class="ap-rtable__label" aria-hidden="true">Delivery by</span><span class="ap-rtable__value">7 Oct</span></td>
              <td role="cell" class="ap-rtable__num" data-priority="2"><span class="ap-rtable__label" aria-hidden="true">Cost</span><span class="ap-rtable__value">₹240</span></td>
              <td role="cell"><span class="ap-rtable__label" aria-hidden="true">Status</span><span class="ap-rtable__status ap-rtable__status--box"><svg class="ap-rtable__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m3.5 7.5 8.5-4 8.5 4v9l-8.5 4-8.5-4Z"/><path d="m3.5 7.5 8.5 4 8.5-4M12 11.5v9"/></svg>Packed</span></td>
            </tr>
          </tbody>
        </table>
      </div>
    </div>
    <div class="ap-rtable__foot">
      <p class="ap-rtable__hint" id="rtable-hint">
        <svg class="ap-rtable__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg>
        <span>Scroll for more</span>
      </p>
      <button type="button" class="ap-btn ap-rtable__more" aria-pressed="false" aria-controls="rtable-table" data-ap-more hidden>
        <svg class="ap-btn__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M9.5 4.5v15M15 4.5v15"/></svg>
        Show more columns
      </button>
    </div>
  </div>
</div>

WCAG 2.2

What it meets

The success criteria this pattern takes care of, and how.

  • 1.3.1 Info and Relationships Level A

    Rows, cells and headers stay exposed in every layout: the stacked cards keep role=table, row, columnheader, rowheader and cell.

  • 1.3.2 Meaningful Sequence Level A

    Each card reads in the same order as its table row, from the shipment number to its status.

  • 1.4.10 Reflow Level AA

    At 320 pixels nothing makes the page scroll sideways: the table scrolls in its own region, stacks into cards, or drops columns.

  • 2.1.1 Keyboard Level A

    The scroll region takes focus so the arrow keys scroll it, and the layout switch and column toggle are native controls.

  • 2.4.7 Focus Visible Level AA

    The scroll region, the layout switch and the toggle all show a two-pixel focus ring.

  • 4.1.2 Name, Role, Value Level A

    The column toggle reports pressed or not pressed, and names the table it controls.

Usage

When to use it

Use it

  • Tables with more columns than a phone can show, such as orders, shipments or schedules.
  • Scroll when people compare across rows; stack when each row is read on its own; drop columns when a few fields matter most.

Use something else

  • Tables people must compare column by column on a phone: stacking breaks the side-by-side view, so let it scroll.
  • Hiding columns with no way back: anything removed for small screens needs a control that shows it again.
  • One record's fields: a description list is simpler than a one-row table.

Common failures

How it usually goes wrong

  • display: block on table parts with no roles

    Older browsers, Safari before version 17 among them, stopped announcing rows, cells and headers once those parts were restyled as blocks or grid items. Explicit roles keep the table, whatever the CSS.

  • Column names drawn with CSS content

    Labels from ::before and attr() are read along with the real headers and are skipped by translation tools. The labels here are real text, hidden from screen readers because the headers already name each cell.

  • A scroll area with no tab stop

    If the region cannot take focus, keyboard users cannot scroll it and never see the right-hand columns.

  • No sign that there is more

    A table cut at the edge can look complete. A shadow on the clipped side and a Scroll for more hint say there is more to see.

  • display: contents on the table

    It still strips table semantics in some browsers, because of how the specification defines it. Use roles, or keep the table's own display values.

  • Hidden columns gone for good

    Dropping columns with display: none removes them for everyone, screen reader users included. The toggle puts them back for anyone who needs them.

Notes

Building it

  • The scroll layout needs a role=region with a name and tabindex=0 on the wrapper; the script removes the tab stop and the hint while the table fits.
  • The stacked layout keeps thead in the page but visually hidden, so screen readers still have column headers; the labels in each card are aria-hidden.
  • Explicit roles are only needed where table parts change display. A table that keeps its own display values, as in the scroll and priority layouts, needs none.
  • Mark column priority with data-priority on both the th and its cells, and hide by container width, not viewport width, so the table adapts wherever it is placed.
  • The edge shadows are decoration, empty pseudo-elements shown from the scroll position; the visible hint says the same thing in words.

Sources: Adrian Roselli: Tables, CSS Display Properties, and ARIA · Adrian Roselli: Under-Engineered Responsive Tables · WCAG 2.2 Understanding: Reflow

Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction