Container Query Data Tables: Reflowing Tables to Cards

A wide data table is hostile to narrow space. Inside a full-width page it scans fine, but drop the same table into a 360px card or a dashboard sidebar and it either overflows or shrinks columns into unreadable slivers. This guide, part of the responsive component patterns collection within Mastering Container Queries & Responsive Layouts, shows how to reflow a table into a stacked card layout based on the table's own container width using @container, while being honest about the accessibility cost of doing so.


Approach rationale: container width and the semantics tradeoff

The layout problem is a perfect fit for container queries. A table cares about how much horizontal room it has, not how wide the screen is, so wrapping it in a container-type: inline-size ancestor lets one @container rule serve the table wherever it lands. That part is uncontroversial.

The accessibility problem is harder and is the reason this pattern needs care. A native <table> carries implicit ARIA roles — table, row, rowgroup, columnheader, cell — that screen readers use to announce "row 3, column Price, $40". The moment you set display: block or display: grid on <tr> or <td> to stack them, browsers drop those implicit roles, and the table stops being a table to assistive technology. You have two defensible choices: reattach the roles explicitly with role="table", role="row", role="cell", or skip reflow entirely and keep a real, horizontally scrollable table. The implementation below uses the explicit-role approach and labels each cell with a visible header so sighted card readers also keep their bearings.


Complete working implementation

Paste this into an HTML file and narrow the wrapper to watch each row become a card. The data-label attributes drive the per-cell header that appears only in card mode.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<style>
  /* The wrapper is the query container; the table queries THIS width. */
  .table-shell {
    container-type: inline-size;
    container-name: datatable;
    max-width: 100%;
  }

  table { width: 100%; border-collapse: collapse; }
  caption { text-align: left; font-weight: 700; padding: 0.5rem 0; }
  th, td { text-align: left; padding: 0.6rem 0.75rem; border-bottom: 1px solid #ddd; }
  thead th { font-weight: 700; }

  /* The per-cell label is hidden in wide mode; the column header already shows. */
  td::before { content: ""; }

  /* NARROW: when the container is small, stack each row as a card.
     We set display on table parts, which strips implicit ARIA roles,
     so the markup re-declares roles explicitly (see the HTML below). */
  @container datatable (max-width: 520px) {
    thead {
      /* Visually hide the header row but keep it in the accessibility tree. */
      position: absolute;
      width: 1px; height: 1px;
      overflow: hidden; clip-path: inset(50%);
    }

    table, tbody, tr, td { display: block; width: 100%; }

    tr {
      border: 1px solid #ccc;
      border-radius: 8px;
      padding: 0.5rem 0.75rem;
      margin-bottom: 0.75rem;
    }

    td {
      display: grid;
      grid-template-columns: 40% 1fr;
      gap: 0.5rem;
      border-bottom: 1px solid #eee;
      padding: 0.4rem 0;
    }
    td:last-child { border-bottom: 0; }

    /* Show the mirrored header next to each value. attr() reads the
       data-label, so no duplicated visible markup is needed. */
    td::before {
      content: attr(data-label);
      font-weight: 600;
      color: #444;
    }
  }
</style>
</head>
<body>
  <div class="table-shell">
    <!-- Roles are declared explicitly so the card display does not erase
         table semantics for screen readers. -->
    <table role="table">
      <caption>Recent 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">
          <td role="cell" data-label="Order">#1042</td>
          <td role="cell" data-label="Customer">A. Okafor</td>
          <td role="cell" data-label="Status">Shipped</td>
          <td role="cell" data-label="Total">$58.00</td>
        </tr>
        <tr role="row">
          <td role="cell" data-label="Order">#1043</td>
          <td role="cell" data-label="Customer">M. Singh</td>
          <td role="cell" data-label="Status">Pending</td>
          <td role="cell" data-label="Total">$129.50</td>
        </tr>
        <tr role="row">
          <td role="cell" data-label="Order">#1044</td>
          <td role="cell" data-label="Customer">L. Romero</td>
          <td role="cell" data-label="Status">Refunded</td>
          <td role="cell" data-label="Total">$12.00</td>
        </tr>
      </tbody>
    </table>
  </div>
</body>
</html>

The diagram below shows the transition the single @container block produces.

Table to stacked cards transition Left: a row-and-column table. Right: each row stacked as a card with label and value pairs. Wide container Narrow container Order Customer Status Total #1042 A. Okafor Shipped $58 #1043 M. Singh Pending $130 @container (max-width: 520px) Order #1042 Customer A. Okafor Status Shipped Total $58.00 Order #1043 Customer M. Singh Status Pending Total $129.50

Key technique callout: attr() in content

The mechanism that makes a stacked cell self-describing is td::before { content: attr(data-label); }. In card mode the column header row is removed from view, so each value would otherwise be a context-free string. attr() pulls the per-cell label out of the data-label attribute and renders it as generated content, pairing "Status" with "Shipped" inside the same grid cell. Because the label lives in an attribute rather than duplicated text nodes, screen readers do not read it twice, and the visible label stays in lockstep with the markup with no JavaScript syncing two copies.


Variation: keep a scrollable real table for dense data

Reflow is the wrong call for dense numeric tables that users compare by column. For those, preserve the genuine table and let it scroll horizontally, which keeps every implicit ARIA role intact. This sizing approach pairs well with the intrinsic sizing techniques used across this site.

@container datatable (max-width: 520px) {
  .table-shell {
    overflow-x: auto;
    /* Make the scroll affordance discoverable and keyboard-reachable. */
    -webkit-overflow-scrolling: touch;
  }
  table { min-width: 40rem; } /* force columns to keep readable width */
}

Add tabindex="0" and an aria-label to the scroll container so keyboard users can reach and pan it. This variant has zero semantic cost because the <table> never changes its display type.


Browser support note

Size container queries are Baseline since Chrome and Edge 105, Safari 16, and Firefox 110, so @container is dependable in 2026. The attr() function in content and clip-path for visually hiding the header are universally supported across those same engines. For pre-2023 browsers, wrap the reflow in @supports (container-type: inline-size) and fall back to the scrollable-table variant, which needs no modern features at all.


FAQ

Why reflow a table with a container query instead of a media query? A container query reacts to the table wrapper's own width, so an embedded table inside a narrow card or sidebar collapses correctly regardless of viewport. Media queries only see the screen and misjudge embedded contexts.

Does turning a table into cards break accessibility? It can. Setting display values other than table on table elements removes their implicit ARIA table roles, so screen readers no longer announce rows, columns, and headers. Restore semantics with explicit role attributes or keep a horizontally scrollable real table as the fallback.

How do I show column headers next to each value in card mode? Mirror each header into a data-label attribute on the cell and render it with a CSS ::before that reads content: attr(data-label). This keeps the visible label in sync without duplicating markup the screen reader reads twice.

Should every table reflow into cards on small screens? No. Dense numeric tables that users scan by column are often better kept scrollable. Reflow suits record-style data where each row is an independent entity, like orders or users.


Related articles

More pages in the same section.