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.

The narrow version of the problem is worth stating precisely, because it is not "tables are bad on mobile". A <table> sizes itself from its content: the browser measures every cell, distributes width across the columns, and the result is a box with a hard minimum width somewhere around the sum of each column's longest unbreakable word. Give it less room than that and one of three things happens — the table overflows its parent and pushes a horizontal scrollbar onto the whole document, overflow-x clips it, or the columns compress until every cell wraps to three lines and the grid stops reading as a grid. None of those is a design decision; they are all failure modes of a box that was never told what to do when it does not fit. Reflow is the decision.


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.

Be clear-eyed about how sturdy that repair is. Re-declaring role="table", role="row" and role="cell" restores the roles in the accessibility tree, and that is genuinely most of the benefit — a screen reader announces the structure again. What it does not reliably restore is table navigation mode, the dedicated cell-by-cell cursor that lets a user move between rows and columns and hear the relevant header re-announced on each move. That behaviour depends on the pairing of screen reader and browser, and it is not something a role attribute alone can guarantee. So the reflowed table is an improvement over a broken layout, not a lossless transformation, and the choice between reflowing and scrolling should be made per table with that in mind.

The threshold itself is a property of the column set. Estimate the narrowest width at which each column is still readable — a status word needs maybe 6rem, a currency amount 5rem, a name 8rem — sum them, add the horizontal padding, and reflow just below that total. The four columns in the example land at roughly 520px, which is why that number appears in the query prelude. The practical consequence is that the threshold is per-table: adding a fifth column moves it, and two tables with different schemas in the same interface should not share one constant.


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.

A scrolling table also wants its first column pinned, otherwise a user who has panned three columns to the right no longer knows which record each value belongs to. Sticky positioning handles that without changing any display type, so the semantics stay intact:

@container datatable (max-width: 520px) {
  /* Pin the first cell of every row so the record stays identifiable
     while the remaining columns pan under it. */
  th:first-child,
  td:first-child {
    position: sticky;
    left: 0;
    /* An opaque background is required: sticky cells overlap the cells
       they scroll over, and a transparent one shows both at once. */
    background: Canvas;
    z-index: 1;
  }

  /* The header row pins vertically for long tables. The corner cell
     needs the higher stacking order because it is sticky on both axes. */
  thead th { position: sticky; top: 0; background: Canvas; }
  thead th:first-child { z-index: 2; }
}

Sticky table cells are one of the few places where position: sticky needs to go on the cells rather than the row: <tr> is not a valid sticky containing block in every engine, so pinning the row instead of its cells fails silently in some browsers and works in others.


Keeping the labels honest

The one maintenance hazard in this pattern is that data-label duplicates a string that already exists in the <th>. Nothing enforces agreement between them, so renaming a column header to "Order total" while the cells still say data-label="Total" produces a card view that quietly contradicts the table view, and no test catches it because both render fine.

Two mitigations are worth the effort. If the table is generated from a template or component, emit the data-label from the same column-definition object that emits the <th>, so a rename touches one place. If the markup is hand-authored, add a check to your test suite that walks each row and asserts that the nth cell's data-label equals the nth header's text content — that is a handful of lines and it turns a silent content bug into a failing assertion. Avoid the third option of copying header text into cells with JavaScript on load: it reintroduces the runtime dependency the CSS-only approach was chosen to avoid, and it runs after first paint.


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, as is position: sticky on table cells, which every current engine handles. The Canvas system colour used in the sticky variant is well supported in current versions of all four engines but is the one value there worth checking against your own support floor; substituting an explicit background colour costs nothing if you are unsure.

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. That fallback is unusually comfortable here because the two options are not a good version and a degraded one — the scrollable table is a legitimate final answer for many datasets, so browsers that miss the enhancement land on a design you would ship deliberately.


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.