Responsive Tables
Tables are the layout that resists responsiveness hardest — rows and columns are a genuinely two-dimensional relationship, and shrinking the viewport doesn't remove any of that data. There is no single correct answer; the right strategy depends on how many columns you have, which ones matter most, and whether the table needs to stay a table semantically for assistive technology.
Strategy 1 — horizontal scroll wrapper
The simplest, most broadly compatible fix: wrap the table in a scrollable container and let it overflow. Nothing about the table itself changes, so its semantics and screen-reader behavior stay perfectly intact.
<div class="table-scroll">
<table>
<!-- full table, unchanged -->
</table>
</div>.table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
table {
min-width: 640px; /* forces the scroll instead of squeezing columns unreadably */
border-collapse: collapse;
width: 100%;
}Table tutorial component uses — a horizontal-scroll wrapper is often the least risky choice for a general-purpose table you don't fully control the content of.Strategy 2 — sticky first column
When a table must scroll horizontally, keeping a label column (like a row name) visible while the rest scrolls underneath gives the user context they'd otherwise lose.
table td:first-child,
table th:first-child {
position: sticky;
left: 0;
background: #fff; /* opaque — otherwise scrolling content shows through */
z-index: 1;
box-shadow: 2px 0 4px rgb(0 0 0 / 0.06); /* subtle separation cue */
}position: sticky(see position: sticky) needs an explicitleft/topand works within the nearest scrolling ancestor — here, the.table-scrollwrapper.An opaque
backgroundis required or the scrolling cells behind it will show through the sticky column as it overlaps them.A small
box-shadoworborderon the sticky edge helps users notice it is pinned rather than just part of the flow.
Strategy 3 — collapse to cards (data-label pattern)
Below a breakpoint, drop the tabular grid entirely and re-render each row as a small labeled card — each cell becomes its own "field: value" line using the CSS-generated content trick with a data-label attribute.
<table class="responsive-cards">
<thead>
<tr><th>Name</th><th>Role</th><th>Status</th></tr>
</thead>
<tbody>
<tr>
<td data-label="Name">Amara Okafor</td>
<td data-label="Role">Engineer</td>
<td data-label="Status">Active</td>
</tr>
</tbody>
</table>@media (max-width: 640px) {
.responsive-cards thead {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip: rect(0 0 0 0); /* visually hidden but still announced */
}
.responsive-cards, .responsive-cards tbody, .responsive-cards tr, .responsive-cards td {
display: block;
width: 100%;
}
.responsive-cards tr {
margin-block-end: 1rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 0.75rem;
}
.responsive-cards td {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.4rem 0;
border-bottom: 1px solid #f1f5f9;
}
.responsive-cards td::before {
content: attr(data-label);
font-weight: 600;
color: #475569;
}
.responsive-cards td:last-child {
border-bottom: none;
}
}display: none — screen readers still benefit from the table structure being announced even when it's rendered as stacked cards.Strategy 4 — priority columns (progressive hiding)
For data-dense tables (analytics, admin panels), hide lower-priority columns first as space shrinks, keeping the table a real table at every width rather than switching representations.
@media (max-width: 900px) {
td[data-priority="3"], th[data-priority="3"] { display: none; }
}
@media (max-width: 700px) {
td[data-priority="2"], th[data-priority="2"] { display: none; }
}
@media (max-width: 500px) {
td[data-priority="1"], th[data-priority="1"] { display: none; }
}Mark each column with a data-priority from most disposable (1) to least (3), then hide the lowest-priority tier first as the viewport shrinks — the highest-priority column (usually the row's identifier) never disappears.
Comparing the four approaches
Strategy | Preserves table semantics | Data loss | Complexity |
|---|---|---|---|
Horizontal scroll | Yes — no change | None | Very low |
Sticky first column | Yes | None | Low |
Collapse to cards | Yes (still a | None, but loses row/column scanning | Medium |
Priority column hiding | Yes | Hidden columns are gone unless revealed | Medium |
Accessibility notes
Never convert
<table>markup into<div>s to "make it responsive" — you throw away the row/column relationships a screen reader announces (th scope, cell counts, header associations) for a purely visual fix.If you hide columns, make sure the remaining, visible ones still make sense standalone — a hidden "Status" column with no substitute leaves sighted mobile users guessing.
For the horizontal scroll pattern, make the scroll container keyboard-focusable (
tabindex="0"withrole="region"and anaria-label) so keyboard users can actually scroll it, not just mouse/touch users.
Related pages: position: sticky, Multi-Column Layout, and table display values.