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).

What CSS reflow does to table semantics Comparison of the accessibility tree for a reflowed table without explicit roles against the same table with explicit ARIA table roles. What CSS reflow does to table semantics✗ display:block, no rolesWebKit may expose rows as generic groupsCells lose their column header associationTable commands stop working on iOS✓ display:block plus explicit rolesrole="table", "row", "cell" restore the treeHeaders still announced with each valueVoiceOver rotor still lists the tablePseudo-element labels hidden from speech
The CSS is identical on both sides; only the role attributes differ.

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
One row reflowed as a card Mock of a table row displayed as a card, with the row header as title and each cell prefixed by its column label, numbered to show which parts are exposed to assistive technology. One row reflowed as a cardCard partShown asSpoken asRow headerSO-2202 (title)1SO-2202, row headerCustomer ce…Customer: Contoso2Customer, Contoso3Status cellStatus: PendingStatus, PendingTotal cellTotal: 420.00Total, 420.001The th scope="row" is the card's title —every card is identifiable2"Customer:" comes from data-label via CSScontent, with empty alt text3The spoken header comes from the visuallyhidden thead, not the pseudo-label
The visible labels are decoration; the column headers in the hidden thead still do the semantic work.

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.

Cards, scroll, or fewer columns? Decision tree for a responsive table based on whether users compare down columns or read one record at a time. Cards, scroll, or fewer columns?Do users compare values down a column, or read onerecord at a time?One record at a timeStacked cardsexplicit roles, pseudo-labels hiddenCompare down columnsHorizontal scrollfocusable, named scroll containerMany optional columnsColumn chooserhide what the user does not need
The reading task decides the pattern — not the number of columns.

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.

Permalink to "Related"

← Back to Responsive Data Tables