Building Responsive Data Tables That Preserve Semantic Markup

Preserve semantic table markup and accessibility while making data tables responsive using container queries and overflow techniques, not div hacks.

The Semantic Contract, and What Breaks When You Drop It

The fastest way to ruin a data table on a phone is to convert it into a grid of divs. The pattern appears in production: display: grid on a wrapper, each row a div role="row", each cell a div role="cell". Every accessible relationship is rebuilt by hand with ARIA attributes. It works until a screen reader update changes what it exposes. Or until someone forgets one scope attribute. Then the accessibility tree silently loses the column-header-to-cell link that sighted users take for granted. The semantic <table> gives you that link for free: the headers attribute, the scope attribute, the implicit rowgroup and columnheader roles, the whole WCAG 1.3.1 structure. When you switch to divs, you are not restyling a table; you are deleting a feature and hoping ARIA fills the hole. This guide shows you how to keep the <table>, keep its semantics, and still get a layout that survives a narrow phone screen. The technique is called responsive data tables CSS, and it is not a single trick but a choice between two honest ones.

The first clue that your table will break is when you set display: block on the <table> element and stop there. The CSS Display Module Level 3, a W3C Recommendation, explicitly allows any display value on table elements, not just the table-* values. But that permission is a trap: it means you must set display on every part, thead, tbody, tr, th, td, or leave the parts with their default display: table-* values, which fight your block layout. The most common mistake is exactly that: applying table { display: block; } and calling it done, then wondering why the tbody still behaves like a row group. The fix is to treat the table as a set of cooperating elements, not a single box. You set thead { display: none; } to hide the header row, tr { display: block; } to stack rows, and td { display: block; } to stack cells. But before you write that CSS, read the next section, because there is a prior decision to make: overflow or reflow.

Overflow-X with a Sticky First Column: The Wide-Table Lifesaver

For tables that are genuinely wide, financial reports, comparison matrices, log dumps, the simplest responsive table pattern is not to reflow anything. You wrap the <table> in a <div style="overflow-x: auto;"> and let the user pan horizontally. That is a CSS data table overflow technique with a twenty-year pedigree: overflow-x is Baseline Widely Available in every engine, and the table keeps its native semantics because you never touch its display value. The risk is usability: a user scrolling a wide table on a phone loses the row labels as soon as they scroll right. The fix is a sticky first column. On the th:first-child and td:first-child, set position: sticky; left: 0; and give them a background so the content does not show through. This keeps the row identifier visible while the rest of the table slides underneath.

The gotcha is that position: sticky on table cells is finicky. The cell must have an explicit background, because the default is transparent, and the border-collapse: collapse value on the table can cause the sticky cell to slide under its own border. Switch to border-collapse: separate; border-spacing: 0; to give the sticky cell a clean edge. For the table itself, set min-width to the sum of your column widths, or use white-space: nowrap on the <td> elements to prevent wrapping; otherwise, the cells will shrink and the table will not actually overflow, it will crush itself. Use min() for column widths when you want the table to shrink under a certain size: td { width: min(200px, 100%); } lets narrow columns breathe on mobile but caps them on desktop. This approach is not for every table, it fails for tables with many columns that each need to be readable, but it is the right default for data that is dense and needs comparison across rows.

Container Query Data Table Layout: Reflow Only When the Container Fails

Why Container Queries Beat Media Queries

The modern alternative is a container query data table layout. Instead of asking the viewport how wide it is, you ask the table's own wrapper. You declare container-type: inline-size on a wrapper element, which creates a containment context for the inline axis. Then you write @container (max-width: 600px) to apply reflow rules only when that specific wrapper, not the whole page, is too narrow. This is the correct tool because tables do not live at the viewport edge; they live inside sidebars, cards, and article columns. A table that is fine in a full-width article might be cramped inside a two-column layout, and a media query cannot know that. The container query can, and it is the difference between a table that breaks your layout and one that adapts to its host.

Building the Reflow Rules

The reflow CSS is identical to the old media-query pattern, but scoped to the wrapper. You hide the thead with display: none, set tr { display: block; } and td { display: block; }, and use generated content to label each cell: td::before { content: attr(data-label); }, where each <td> carries a data-label attribute in the HTML. That last step is non-negotiable: without data-label, the reflowed table is a wall of unlabelled numbers. The td::before pseudo-element becomes the visual header, and you style it with font-weight: bold and a right margin. The cell itself becomes a flex container if you need the label and value on the same line, but the simplest pattern is to let the label sit above the value, which works for narrow widths.

The Fallback Stack

The critical detail is the @supports query. Container queries shipped in Chromium and Firefox, and Safari added container-type: inline-size in version 16.0. You can use @supports (container-type: inline-size) to gate the container-query version, and fall back to a media query on the table's wrapper width for older engines. The fallback is not perfect, it uses the viewport, not the wrapper, but it is a reasonable degradation because tables are rarely the only element affected by viewport width; if the viewport is narrow, the wrapper likely is too. Write the fallback first, then the container query overrides it.

/* Fallback: media query on viewport */
.table-wrapper { overflow-x: auto; }
@media (max-width: 600px) {
  .table-wrapper table { display: block; }
  .table-wrapper thead { display: none; }
  .table-wrapper tr { display: block; }
  .table-wrapper td { display: block; }
  .table-wrapper td::before { content: attr(data-label); }
}
/* Modern: container query */
@supports (container-type: inline-size) {
  .table-wrapper { container-type: inline-size; }
  @container (max-width: 600px) {
    table { display: block; }
    thead { display: none; }
    tr { display: block; }
    td { display: block; }
    td::before { content: attr(data-label); }
  }
}

This pattern gives you the accessible responsive table pattern you came for. The <table> element, <th> with scope="col", and <td> cells remain in the HTML, so the accessibility tree still has the column-header relationships, even when the visual layout changes. The display: block switches do not remove the table semantics in modern screen readers, but to be safe, add role="table", role="row", and role="cell" if you are concerned about older assistive tech that relies on the computed display value. The ARIA in HTML specification warns that changing display from table-* can remove native semantics, so the explicit roles are a cheap insurance policy.

Semantic HTML Table Responsive Design: The Data-Label Contract

Every reflow pattern hinges on one thing: the data-label attribute in your HTML. Without it, the most elegant CSS is pointless. The data-label is not a presentational hook; it is the semantic counterpart to the visual header that the reflowed layout shows. When you write <td data-label="Revenue">$1,200</td>, you are giving the cell a name that the CSS can project back when the header is hidden. This is the semantic HTML table responsive design principle: the content carries the meaning, and CSS only changes how it is presented. The headers attribute exists on the <td> element itself and can reference multiple ids on <th> cells, but data-label is simpler for this purpose because it does not need to match a header id; it is a new name for a new context.

The mistake to watch for is using data-label only in one row. You must add it to every <td> in the table, because any cell might be the one the user sees without its header. This feels like duplication, and it is, but it is the cost of a reflowable table. An alternative is to use the aria-describedby attribute to point to a hidden header cell, but that requires the header to remain in the accessibility tree, which conflicts with thead { display: none; } (the display: none removes the header from the tree entirely). So data-label is the practical choice. For columns you want to hide entirely, use the collapsed-columns technique: in a @container query, set .optional-col { display: none; }. This removes the column from layout and from the accessibility tree, which is correct if the column is irrelevant on mobile. If you need the column to remain accessible but visually hidden, use visibility: collapse on the <col> element, but that has inconsistent browser support, so test it. For most cases, plain display: none is what you want.

Container Query vs Media Query: Picking the Right Breakpoint

Why bother with container queries when media queries work in every browser since 2010? Because media queries lie. They tell you about the viewport, not about your table. A table inside a sidebar on a wide desktop is not itself wide, but the viewport says it is, so no media query will trigger. A container query sees the sidebar's width and reflows at that point. That is the container query vs media query distinction in practice: media queries are for document-level layout (sidebar stacks below main), and container queries are for component-level layout (the table inside the sidebar reflows). You use both. The document uses media queries for the page grid, and the table component uses container queries for its own internal responsiveness.

The breakpoint value itself is not a magic number. It depends on your column count and the length of your content. A table with two columns of short numbers can survive down to a very narrow width; a table with six columns of long text starts to fail at 600px. Start with @container (max-width: 600px) and test with real content at common phone widths. Check caniuse for the latest support data on container queries. The goal is not to reflow early; it is to reflow at the point where the table starts to be unreadable. That point is where the cells squeeze, the text overflows, or the min-content width of the table exceeds the wrapper. If you want a more adaptive column width, use clamp(): td { width: clamp(100px, 20%, 300px); } scales the column between 100 and 300 pixels as the wrapper grows. Combine this with text-overflow: ellipsis on cells that cannot wrap, but be careful: ellipsis hides content, so use it only for non-essential columns. For long unbroken strings like URLs, use word-break: break-word on the <td> to force line breaks.

Writing the Table Markup That Survives the Switch

Before you write a single CSS rule, make sure your HTML is valid and complete. The <table> must have a <caption> (the caption-side property controls whether it appears above or below), and every <th> needs a scope attribute: scope="col" for column headers, scope="row" for row headers. The headers attribute on <td> is optional if you use scope correctly, but it helps screen readers when you have multi-row or multi-column headers. Add aria-describedby on the <table> to point to a paragraph that summarizes the table's purpose, which satisfies WCAG 1.3.1. This is the contract you are preserving; the CSS cannot create semantics that the HTML does not declare.

<table aria-describedby="sales-summary">
  <caption>Q3 Revenue by Region</caption>
  <thead>
    <tr>
      <th scope="col">Region</th>
      <th scope="col" class="optional-col">Q3 Revenue</th>
      <th scope="col">Change vs Q2</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">North</th>
      <td class="optional-col" data-label="Q3 Revenue">$1.2M</td>
      <td data-label="Change vs Q2">+4.3%</td>
    </tr>
  </tbody>
</table>

Note how the optional-col class appears on both the <th> and the corresponding <td>. The container query hides those columns when the wrapper is too tight, using @container (max-width: 500px) { .optional-col { display: none; } }. This is a cleaner alternative to reflowing the entire table, you keep the row headers visible and only drop the least critical data. The :has() selector can automate this: table:has(td:nth-child(2):empty) .optional-col { display: none; } hides a column if all its cells are empty, which is useful for dynamic data. But :has() lacks support in older Safari (before 15.4), so test your fallback.

When Reflow Is Wrong: Tables That Should Scroll Instead

Not every table should reflow. A table with many columns and few rows, say, a comparison of browser support, becomes a vertical scrolling mess when reflowed, because each row becomes a full block of label-value pairs. For those, the overflow technique is the better choice. You make the wrapper scroll horizontally, and you keep the table as-is. The accessibility tree is untouched, and the user swipes sideways. The cost is discoverability: users may not know there is more content off-screen. A subtle gradient on the right edge or a partial visible column hints at it. The sticky first column is the key improvement over a plain scroll, because it keeps the row identifier in view. Set the background of the sticky cell to match the table's background (background: #fff on a white page) and add a box-shadow: 1px 0 0 rgba(0,0,0,0.1) for a visual separator. This pattern is the same as a frozen first column in spreadsheet software, but it works because the table is a table, and the user's mental model of a table includes both dimensions.

The failure mode to watch for is when the table is too narrow and the sticky column takes up too much width. If the first column is 40% of the table, sticky will eat the screen. In that case, reflow is the right answer, or you shrink the first column with width: min(120px, 30%). There is no perfect answer; the choice is between horizontal scrolling and vertical stacking, and the right one depends on the data's shape. A table of prices by month reads well vertically; a table of specs across products reads well horizontally. Do not force one pattern on all tables.

A Table of the Two Paths

TechniqueWhen to UseAccessibility ImpactBrowser Support
Overflow-x with sticky first columnWide tables, many columns, few rows; data needs side-by-side comparisonPreserves full native table semantics; no ARIA roles neededAll engines, Baseline Widely Available since 2000s (overflow-x); sticky column needs modern browsers but position: sticky is Baseline Widely Available since 2017
Container query reflow with data-labelTables with 3-6 columns, long text; needs to be read as cards on narrow screensLoses visual headers; requires data-label for screen readers; add role attributes for older assistive techContainer queries: shipped in Chromium, Firefox, and Safari 16+; fallback via media query works everywhere

FAQ: The Four Questions People Actually Ask

Q: Does changing display on a <table> to block break screen readers?
In modern screen readers, the <table> element retains its semantics as long as the HTML is intact. But per the ARIA in HTML spec, some older assistive tech relies on the computed display: table-* value. Add role="table", role="row", and role="cell" to be safe, and test with your target screen reader.

Q: What is the best breakpoint for a responsive table?
There is none. It depends on the table's column widths and content length. Start at 600px for a typical 4-6 column table, then test at common phone widths. Use a container query on the wrapper, not a media query, so the breakpoint responds to the table's own space, not the viewport.

Q: Should I use text-overflow: ellipsis on table cells?
Only for non-essential columns. Ellipsis hides content, which can violate WCAG success criterion 1.4.10 (reflow). If you truncate, ensure the full content is available via a tooltip or a separate accessible text node. Prefer word-break: break-word to wrap long strings instead.

Q: Can I use CSS Grid to make a responsive table?
Yes, but you must replace the <table> with <div role="table"> and assign explicit role="row" and role="cell". This works in all modern browsers (Baseline 2017), but it abandons the native table semantics, so you must replicate them with ARIA. It is more code and more risk; prefer the CSS-only display-switch method.

The Honest Caveat: When Your Table Is Too Big

All of this works because the table is small enough to reflow or scroll. But a table with 50 columns and a huge row count will crush any CSS technique. The reflow pattern produces a page that is thousands of blocks tall, and the overflow pattern becomes an unreadable horizontal sliver. The honest advice is to stop using a table at that point: pivot the data into a chart, split it into multiple focused tables, or use a client-side virtual scrolling library that renders only the visible rows. CSS cannot fix a data-design problem. The container query and overflow techniques are for the vast majority of tables that are small and semantic; the rest need a different tool, and pretending otherwise is a disservice to your users.