Responsive Data Tables: Patterns That Survive Real Content
Responsive table CSS that survives real content: scroll containers, sticky headers, frozen columns, card stacking and the ARIA roles that break silently.
A client once sent us a bug report with a screenshot: their pricing comparison table, four plans across, rendered on an iPhone SE as four columns about 60px wide each, every plan name broken onto six lines, the word “Enterprise” hyphenating itself into “Ente-rprise”. The table was “responsive”. It had width: 100% and nothing else. That’s the state of most responsive table CSS in the wild: a percentage width, a prayer, and a layout that collapses the moment real content arrives.
Tables are the hardest component to make responsive because they’re the only one where the content’s shape is the information. A card can reflow. A nav can collapse. A table that reflows wrong stops being a table and becomes a list of numbers with no context.
So here’s what actually holds up, after a lot of shipped dashboards, spec sheets, invoice listings and comparison grids. Including the patterns we’ve abandoned.
- Pick the pattern from the table’s job, not its width: lookup tables want horizontal scroll, comparison tables want column priority, and transactional lists want row-to-card transformation.
- Changing
displayontable,trortdstrips table semantics in Chrome and Safari. If you use the stacked card pattern you must re-addrole="table",role="row"androle="cell"in the markup, or screen reader users lose row and column association entirely. - A scroll container is only accessible if keyboard users can reach it: wrap in
role="region"with an accessible name and addtabindex="0"only when the table actually overflows. border-collapse: collapsebreaks borders on sticky headers. Useborder-collapse: separate; border-spacing: 0and draw borders on the cells.content-visibility: autodoes nothing for a table withtable-layout: auto, because the browser still has to measure every cell to compute column widths. Set fixed layout first or paginate.
Three kinds of table, three different answers
Most advice treats “data table” as one thing. It isn’t. We sort tables into three buckets before writing a line of CSS, and the bucket decides the pattern.
Lookup tables. Many rows, the user scans for one row and reads across it: transaction history, server logs, order lists. The user cares about a few columns at a time. Horizontal scroll with a frozen identity column is correct here, and it’s almost always the right default.
Comparison tables. Few columns that exist to be compared against each other: pricing tiers, product specs, plan features. Breaking these into stacked cards destroys the entire point, because comparison requires simultaneous visibility. These need column priority, a column selector, or a deliberate rotation so features become rows and plans become columns.
Transactional lists. Each row is a self-contained entity the user acts on. A users list in an admin panel, with a name, email, role, last login and an edit button. These genuinely work as cards on narrow screens, because nobody compares row 3’s last login against row 47’s. This is the only bucket where the stacked card pattern earns its keep.
Get the bucket wrong and no amount of CSS saves you. We’ve watched a team spend two sprints making a 9-column spec comparison stack into cards, then quietly ship horizontal scroll instead because users couldn’t compare anything.

Horizontal scroll is the default, and your responsive table CSS should make it obvious
Scroll gets a bad reputation because it’s usually implemented badly: no visual hint that content continues, no keyboard access, and the whole page scrolling sideways because someone put overflow-x: auto on body.
Done properly it’s the most honest pattern available. Nothing is hidden, nothing is reordered, semantics stay intact.
<div class="table-scroll" role="region" aria-labelledby="tx-caption" tabindex="0">
<table class="data-table">
<caption id="tx-caption">Transactions, March 2026</caption>
<thead>
<tr>
<th scope="col">Reference</th>
<th scope="col">Date</th>
<th scope="col">Counterparty</th>
<th scope="col" class="num">Amount</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">TX-90412</th>
<td><time datetime="2026-03-04">4 Mar</time></td>
<td>Nordwind Logistik GmbH</td>
<td class="num">-1,240.00</td>
</tr>
</tbody>
</table>
</div>
.table-scroll {
overflow-x: auto;
overscroll-behavior-x: contain; / stop sideways swipes triggering back navigation /
container-type: inline-size;
}
.data-table {
border-collapse: separate;
border-spacing: 0;
width: 100%;
}
.data-table th,
.data-table td {
white-space: nowrap; / force scroll instead of squashing columns /
padding: 0.625rem 0.875rem;
border-block-end: 1px solid var(--table-border);
text-align: start; / flips correctly under RTL /
}
/ the one column allowed to wrap /
.data-table td.prose {
white-space: normal;
min-width: 18rem;
overflow-wrap: anywhere;
}
.data-table .num {
text-align: end;
font-variant-numeric: tabular-nums; / digits align across rows /
}
Two details people skip. The tabindex="0" makes the region keyboard-scrollable, which is required, but it creates an unnecessary tab stop when the table fits its container. Toggle it with a ResizeObserver:
const wrap = document.querySelector('.table-scroll');
const sync = () => {
const overflows = wrap.scrollWidth > wrap.clientWidth + 1; // +1 for subpixel rounding
wrap.toggleAttribute('tabindex', overflows);
if (overflows) wrap.setAttribute('tabindex', '0');
};
new ResizeObserver(sync).observe(wrap);
sync();
And font-variant-numeric: tabular-nums is not cosmetic. With proportional digits, a column of currency values is visually jagged and genuinely slower to scan. If your brand font has no tabular figures, stack a system fallback for numeric cells only.
If you’re on Bootstrap 5, .table-responsive gives you the overflow container and nothing else: no region role, no accessible name, no tabindex management. We patch that on every build. The table components in Canvas ship with the wrapper semantics already in place, which saves re-deriving this on each project.
Sticky headers and frozen columns, minus the usual bugs
A frozen first column is what makes horizontal scroll usable: it keeps the row’s identity on screen while you scroll the data. It’s also where three predictable bugs live.
/ 1. Sticky header /
.data-table thead th {
position: sticky;
inset-block-start: 0;
z-index: 2;
background: var(--table-head-bg); / must be opaque or rows bleed through /
}
/ 2. Frozen identity column, logical so RTL just works /
.data-table :is(th, td):first-child {
position: sticky;
inset-inline-start: 0;
background: var(--table-bg);
z-index: 1;
}
/ 3. The corner cell sits above both /
.data-table thead th:first-child { z-index: 3; }
/ 4. Shadow to signal there is more to the right /
.data-table :is(th, td):first-child::after {
content: "";
position: absolute;
inset-block: 0;
inset-inline-end: -1px;
width: 1px;
box-shadow: 2px 0 6px rgb(0 0 0 / 0.18);
}
Bug one: border-collapse: collapse makes borders belong to the table, not the cell, so they do not travel with a sticky element and your header loses its bottom border on scroll. Use separate with zero spacing and put borders on cells. Bug two: a transparent sticky background means scrolled rows show through. Bug three: position: sticky stops working if any ancestor has overflow: hidden, which is very easy to inherit from a card wrapper. If sticky mysteriously does nothing, walk up the tree looking for overflow.
One real limit: sticky headers inside a page-level scroll need scroll-margin-block-start on your anchor targets. On iOS Safari, a sticky thead inside a horizontally scrolling container has historically been the flakiest combination in the stack. Test it on an actual device, not the simulator.
Using inset-inline-start instead of left costs nothing and is one of the few places where logical properties pay off immediately. We cover the rest of that in RTL support and CSS logical properties.
The stacked card pattern, and the thing nobody tells you about it
You know the technique: hide the thead, turn each tr into a block, and inject the column name before each cell with attr().
@container (max-width: 40rem) {
.stack thead { / visually hidden, still in the a11y tree /
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.stack tr {
display: block;
border: 1px solid var(--table-border);
border-radius: 0.5rem;
margin-block-end: 0.75rem;
padding: 0.25rem 0.75rem;
}
.stack td {
display: grid;
grid-template-columns: minmax(7rem, 35%) 1fr;
gap: 0.75rem;
border: 0;
white-space: normal;
}
.stack td::before {
content: attr(data-label);
font-weight: 600;
color: var(--muted);
}
}
Here’s the part the listicles leave out. Setting display: block or display: grid on table elements removes their table role in Chrome and Safari. Not degrades: removes. The row and column relationships a screen reader user depends on are gone, and your carefully labelled cells become a flat pile of text. Firefox is more forgiving, which is exactly why this bug survives code review.
The fix is to declare the roles explicitly in the markup so they survive the display change:
<table class="stack" role="table">
<tbody role="rowgroup">
<tr role="row">
<td role="cell" data-label="Email">[email protected]</td>
<td role="cell" data-label="Role">Editor</td>
</tr>
</tbody>
</table>
Note the container query rather than a media query. The breakpoint should depend on the width available to the table, not the viewport, because the same component gets dropped into a full-bleed page and a 320px sidebar. Requires container-type: inline-size on the wrapper; more on the general approach in container queries are ready.
Where this pattern does not apply: any table with colspan or rowspan, any table with a totals row, and any table where cells are meaningful only relative to their neighbours. A subtotal row stacked as a card reading “Subtotal: 4,200.00” with no context is noise. If your table has merged cells, use scroll.
Column priority for comparison tables
For comparison tables, the useful move is deciding in advance which columns are expendable. Tag them in markup, hide them by container width, and give the user a way to bring them back.
@container (max-width: 56rem) { .data-table [data-priority="3"] { display: none; } }
@container (max-width: 42rem) { .data-table [data-priority="2"] { display: none; } }
Apply the attribute to both the th and the matching td in each row, or use :nth-child() if your column order is stable. The honest caveat: hiding data silently is a usability risk, so pair it with a disclosure. On client dashboards we render a “row detail” expansion: the visible columns are the priority ones, and an expand button reveals the full record in a definition list below the row, with aria-expanded on the trigger and a colspan cell holding the detail.
Avoid the column-chooser dropdown unless the table is a genuine power tool used daily. On a marketing pricing page it’s three extra clicks before anyone sees a price.
Accessible HTML tables: the parts that break silently
Accessible HTML tables are mostly a markup problem, not a CSS problem, and the failures are quiet because sighted testing never surfaces them.
- Every table needs a
caption. It’s the accessible name. If design objects, visually hide it, but don’t drop it. Anaria-labelon the table is an acceptable second choice. scopeon every header.scope="col"in the head,scope="row"on the first cell of each body row, and make that cell ath. This is what lets a screen reader announce “Reference TX-90412, Amount, minus 1,240” instead of just “minus 1,240”.- No layout tables. If you must use one for an email template, add
role="presentation". - Sortable columns need
aria-sorton the activeth, set toascendingordescending, and the control inside the header should be a realbutton. - Don’t nest tables. Navigation becomes unusable. Use the row-detail pattern instead.
- Empty cells need content. A visually empty
tdis announced as “blank” with no explanation. Put a “Not available” label in a visually hidden span, or a literal dash character plusaria-label.
Our standing rule from the wider semantic HTML work: if the data is tabular, use a table. Div grids with ARIA roles are strictly more code to achieve strictly less, and they lose the browser’s built-in table navigation shortcuts.
Stress tests real content will throw at you
Before a table ships, we run it through the same list. Every item here comes from a production bug.
- A 40-character unbroken string. German compound nouns, URLs, support ticket subjects. Does the layout explode or does
overflow-wrap: anywherecatch it? - An email address in a narrow column. Classic overflow source, because browsers won’t break at
@by default. - Negative currency and a thousands separator. Check alignment with and without the minus sign. This is where tabular figures prove themselves.
- Zero rows. An empty state inside a
colspancell, not a collapsed skeleton. - One row. Card-stacked single rows often look like a rendering error.
- Translated headers. “Amount” becomes “Betrag”, fine, but “Last login” becomes “Dernière connexion”. Column widths set in pixels for English copy will break.
- Zebra striping in both themes. Use
rgb(from var(--surface) r g b / 0.04)or acolor-mix()so the stripe derives from the surface rather than being a hardcoded grey that vanishes in dark mode. Same token discipline as in dark mode done properly. - Zoom to 400%. WCAG 1.4.10 reflow territory. A scrolling container is allowed here precisely because tables are exempt from the no-two-dimensional-scrolling requirement, but the surrounding page must still reflow.
When to stop styling and start paginating
CSS stops being the answer somewhere around a thousand rows. An 8-column table with 1,000 rows is 8,000 cells, and with table-layout: auto the browser measures all of them to compute column widths before it can paint anything. On a mid-tier Android with 4x CPU throttling in DevTools, that style and layout pass runs into the hundreds of milliseconds, and it happens again on every resize.
Two mitigations, in order of effort:
.data-table {
table-layout: fixed; / widths from the first row only, no full-table measurement /
}
.data-table col.ref { width: 10rem; }
.data-table tbody tr {
content-visibility: auto;
contain-intrinsic-size: auto 44px; / measured row height, keeps the scrollbar honest /
}
content-visibility on rows only helps once layout is fixed, because skipping render work is pointless if the engine still needs every cell’s intrinsic width. Get the order wrong and you’ll conclude the property does nothing.
Past roughly 500 rows, server-side pagination with a sensible page size beats any client-side cleverness, and it fixes your payload size at the same time. Virtualised rendering is the last resort: it breaks find-in-page, breaks native keyboard table navigation and makes “select all” semantically murky. Use it when the product genuinely is a spreadsheet. Otherwise paginate and add a decent filter.
Frequently Asked Questions
Should I use CSS Grid with divs instead of a table element?
No, not for tabular data. You’d have to recreate every table role with ARIA, and you still lose the screen reader table navigation mode that reads headers as you move between cells. If you want grid-level layout control, apply display: grid to the real table with grid-template-columns: subgrid on the rows, and add explicit role attributes since the display change drops the native semantics.
Is horizontal scrolling an accessibility failure?
Not for tables. WCAG 1.4.10 (Reflow) explicitly exempts content that requires two-dimensional layout, and data tables qualify. What does fail is a scroll container that keyboard users can’t reach, so give it role="region", an accessible name from the caption, and a tabindex="0" when it overflows.
Why does my sticky table header lose its border when I scroll?
Because border-collapse: collapse hands border painting to the table rather than the cell, and the border doesn’t follow the sticky element. Switch to border-collapse: separate with border-spacing: 0 and set borders on th and td directly, or fake the line with a box-shadow.
What do I do about colspan and rowspan on mobile?
Keep the table intact and scroll it. Merged cells carry relational meaning that any stacking transformation destroys, and data-label pseudo-elements have no sensible value for a cell that spans three columns. If the merged cells are purely decorative grouping, remove them and use a header row with scope="rowgroup" instead.
Media query or container query for the breakpoint?
Container query, assuming you can set container-type: inline-size on the table’s wrapper. The same table component often appears in a full-width page and a narrow sidebar, and viewport width tells you nothing useful in the second case. Support is universal in current browsers, so a media query fallback is only needed if you’re still supporting pre-2023 Safari.
Where to start
Open the table you’re worried about and answer one question: does the user compare values across rows, or read one row at a time? Compare means keep the grid and manage columns by priority. Read one row means card stacking is safe, as long as you add the roles back.
Then run the stress list. Paste a German compound noun, an email address and a negative currency value into the widest-looking column, drop the row count to zero, and zoom to 400%. Twenty minutes of that will find more bugs than another round of breakpoint tuning, and it’s the step that separates a table that demos well from one that survives a year of real data.


