Stacked Card Layouts That Keep Table Semantics
Permalink to "Stacked Card Layouts That Keep Table Semantics"A stacked card layout turns each table row into a small card on narrow screens: the row header becomes the card title, and each cell becomes a “Label: value” line. It is the most popular responsive table pattern, and it has a well-known side effect. Setting display: block (or grid, or flex) on <table>, <tr> and <td> elements causes some browsers to drop their table semantics, so a screen reader on a phone hears a flat list of values with no headers at all.
This page shows the CSS that produces cards, the ARIA that restores the semantics the CSS removes, and when to stop fighting and render real cards. It belongs to responsive data tables.
Spec reference
Permalink to "Spec reference"HTML-AAM maps <table>, <tr>, <th> and <td> to table roles, but browsers have historically tied that mapping partly to CSS layout. Safari (WebKit) and older Chromium and Firefox versions removed table roles from elements whose display was changed away from the table values. Current Chromium and Firefox largely keep the roles; WebKit has improved but remains the engine to test. The robust fix is explicit ARIA roles, which the browser honours regardless of CSS: role="table", role="rowgroup", role="row", role="columnheader", role="rowheader", role="cell".
The visible “Label:” text in each card is presentational — it duplicates the column header — so it should be generated with CSS content from a data-label attribute and hidden from assistive technology, or the header will be read twice.
Criteria in play: SC 1.3.1 Info and Relationships (header associations must survive the reflow) and SC 1.4.10 Reflow (content usable at 320 CSS pixels without two-dimensional scrolling — though data tables are an explicit exception, discussed in meeting reflow 1.4.10 with wide tables).
When to use stacked cards — and when not to
Permalink to "When to use stacked cards — and when not to"Use stacked cards for record lists with a handful of fields, where users read one record at a time: contacts, orders, tickets. On a phone, a card per record is genuinely easier to read than a 7-column table scrolled sideways.
Do not use them for tables whose value lies in comparing down a column — financial statements, metrics by period, pivots. Cards destroy column comparison for everyone. For those, keep the table and make it scroll horizontally in a keyboard-accessible container, as in keyboard-scrollable table containers.
The misapplication to name is shipping the card CSS with no roles and testing only on desktop Chrome at a narrow window width. Chrome keeps the semantics, the test passes, and iOS VoiceOver users — the people actually using the card layout — get a flattened list.
Annotated code example
Permalink to "Annotated code example"<!-- SC 1.3.1: explicit roles survive any display value -->
<table role="table" class="stack">
<caption>Open orders</caption>
<thead role="rowgroup">
<tr role="row">
<th role="columnheader" scope="col">Order</th>
<th role="columnheader" scope="col">Customer</th>
<th role="columnheader" scope="col">Status</th>
<th role="columnheader" scope="col">Total</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr role="row">
<!-- row header becomes the card title -->
<th role="rowheader" scope="row">SO-2202</th>
<td role="cell" data-label="Customer">Contoso</td>
<td role="cell" data-label="Status">Pending</td>
<td role="cell" data-label="Total">420.00</td>
</tr>
</tbody>
</table>
@media (max-width: 40rem) {
.stack thead { position: absolute; width: 1px; height: 1px; overflow: hidden;
clip-path: inset(50%); } /* headers stay in the tree */
.stack, .stack tbody, .stack tr, .stack th, .stack td { display: block; }
.stack tr { border: 1px solid var(--color-rule); border-radius: 8px;
margin-block-end: 0.75rem; padding: 0.75rem; }
.stack th[scope="row"] { font-size: 1.1rem; } /* card title */
.stack td::before {
content: attr(data-label) ": "; /* visible label */
font-weight: 600;
/* Pseudo-element content can be read by some AT; the alt-text form hides it */
content: attr(data-label) ": " / "";
}
}
The content: … / "" syntax gives generated content an empty alternative text, so browsers that support it do not expose the label to the accessibility tree — the real column header already supplies it. Browsers that do not support the slash syntax ignore that declaration and fall back to the first one, where the label may be read twice; that is a minor verbosity issue, not a loss of information.
Visually hiding the <thead> (rather than display: none) keeps the column headers in the accessibility tree, so each cell is still associated with its header.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected announcement | AT-specific deviations |
|---|---|---|
| VoiceOver iOS swipe into a card | “SO-2202, row header” | Without roles: “SO-2202” with no table context |
| Swipe to next cell | “Customer, Contoso” | May say “Customer: Customer, Contoso” if the pseudo-label is exposed |
| Rotor → Tables | Table listed as “Open orders” | Absent without explicit roles on older WebKit |
| TalkBack swipe | “Contoso, Customer, column” | Chrome keeps semantics; roles are belt and braces |
| Desktop NVDA at 400% zoom | Table commands still work | Layout switches to cards via the media query |
Integration context
Permalink to "Integration context"Stacked cards are one of three responsive strategies; the others are horizontal scrolling and column hiding. Responsive data tables compares them, and column visibility menus and column choosers covers the third.
Cards and sortable headers do not mix well: the header row is visually hidden in the card layout, so sort buttons need a separate “Sort by” control on narrow screens, and that control must announce the result the same way the header buttons do.
Gotchas
Permalink to "Gotchas"display: contents on rows. Tempting for grid-based layouts, and historically the worst offender: it removed the element’s role entirely in several browsers. Explicit roles fix it here too.
Hiding <thead> with display: none. Removes the column headers from the tree; cells lose their header association. Use a visually-hidden technique.
Interactive cells. Cards with buttons or links per cell are fine, but their names must include the record: “Edit SO-2202”, not “Edit” forty times down the page.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Does display:block on a table remove its accessibility?
It can. Some browsers, historically WebKit in particular, drop table semantics when table elements are given non-table display values. Adding explicit ARIA roles — table, rowgroup, row, columnheader, rowheader, cell — restores them regardless of CSS.
How do I show column labels inside each card without reading them twice?
Generate them with CSS content from a data-label attribute and give the generated content empty alternative text using the slash syntax, so assistive technology uses the real column header instead.
Is a card layout better for screen reader users than a scrolling table?
For reading one record at a time, often yes, as long as semantics survive. For comparing values down a column, no — keep the table and make it scroll in a keyboard-accessible container.
Should I render separate card markup for mobile instead?
If the mobile experience genuinely differs — different fields, different actions — a list of articles with headings can be clearer than a restyled table. Render one or the other, never both, to avoid duplicated content in the accessibility tree.
Related
Permalink to "Related"- Keyboard-scrollable table containers — the scroll-instead-of-reflow alternative
- Meeting reflow with wide tables — what SC 1.4.10 actually requires
- Layout versus data tables — how browsers decide table semantics