CSSResponsive Tables

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.

HTML
<div class="table-scroll">
  <table>
    <!-- full table, unchanged -->
  </table>
</div>

CSS
.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%;
}
Note
This is the pattern this site's own 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.

CSS
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 explicit left/top and works within the nearest scrolling ancestor — here, the .table-scroll wrapper.

  • An opaque background is required or the scrolling cells behind it will show through the sticky column as it overlaps them.

  • A small box-shadow or border on 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.

HTML
<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>

CSS
@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;
  }
}
Tip
Hide the header visually with a clip-based "visually hidden" pattern rather than 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.

CSS
@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 <table> in DOM)

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" with role="region" and an aria-label) so keyboard users can actually scroll it, not just mouse/touch users.