Responsive tables on mobile: 5 patterns that work
Wide tables make the whole page scroll sideways on phones. Five responsive table patterns with code: scroll wrapper, stacked cards, sticky column and more.
On a phone, a table with more than a few columns is usually wider than the screen, and because a table won't shrink below its content, it drags the whole page sideways with it. The fix is to let the table scroll inside its own box, or to change its shape on narrow screens: stack rows as cards, drop low-priority columns, pin the first column, or turn comparisons into lists. Keep the real <table> markup underneath wherever you can.
Why tables spill
With the default table layout, a table can't be narrower than the sum of its columns' narrowest possible widths: the longest word in each column, plus cell padding. width: 100% doesn't change that; the table simply ignores it when the content needs more. Five or six columns of product names, dates and prices quickly add up to more than the 328px a Galaxy S25 (360px wide) leaves after 16px of padding on each side.
When the table is wider than the page, the page gets wider too, and every other section wobbles sideways under your thumb. That's the bug. A table scrolling inside its own box is fine: the W3C's guidance for WCAG 1.4.10 Reflow names data tables as content that may scroll in two directions, because they need their grid to make sense, but the exception covers only the table. The rest of the page still has to fit at 320px wide.
Pick a pattern by what the table is for:
| Your table | Best pattern on phones |
|---|---|
| Numbers people compare across rows and columns | Scroll wrapper, often with a sticky first column |
| Records: orders, invoices, bookings, users | Stacked cards |
| A few columns matter, the rest are extras | Hide lower-priority columns |
| Plans or products compared feature by feature | One list per plan |
| Two or three short columns | Nothing. Let the text wrap |
1. The scroll wrapper
The default fix, and the right one for real data tables. Only the table scrolls; the page stays put.
<p class="scroll-hint" aria-hidden="true">Scroll sideways to see every column →</p>
<div class="table-scroll" role="region" aria-labelledby="visits-caption" tabindex="0">
<table>
<caption id="visits-caption">Monthly visitors by country</caption>
<!-- thead, tbody… -->
</table>
</div>
.table-scroll { overflow-x: auto; }
.table-scroll:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.table-scroll th,
.table-scroll td { padding: 8px 12px; text-align: left; }
/* Hide the hint once the table fits. Tune the width to your table. */
@media (min-width: 48rem) {
.scroll-hint { display: none; }
}
What each piece does:
overflow-x: autoadds a scrollbar only when the table is too wide.tabindex="0"lets keyboard users focus the box and scroll it with the arrow keys. Chrome (from version 132) and Firefox now do this by themselves for scroll boxes that contain nothing focusable (Chrome for Developers), but not every browser does, and in Chrome a table full of links doesn't qualify. Keep the attribute.role="region"andaria-labelledbygive the focused box a role and a name (the caption), so screen reader users know what they've landed on. This is the pattern Adrian Roselli recommends in Under-Engineered Responsive Tables.- The focus outline shows keyboard users where they are.
- The hint. Phone browsers hide scrollbars until you scroll, so nothing tells people there's more to the right. A one-line hint does.
aria-hiddenkeeps screen readers from reading an instruction meant for thumbs.
Still spilling? If the wrapper sits inside a flex or grid item, that parent may refuse to shrink below the table's width. Add min-width: 0 to it; the details are in how to fix horizontal scrolling on mobile.
2. Stack rows as cards
For tables where each row is a record, like orders, a card per row reads better on a phone than a sideways scroll. Mark the table up normally and add a data-label to each cell:
<table class="stack">
<caption>Your orders</caption>
<thead>
<tr>
<th scope="col">Order</th>
<th scope="col">Date</th>
<th scope="col">Status</th>
<th scope="col">Total</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">#1042</th>
<td data-label="Date">3 Sep 2026</td>
<td data-label="Status">Shipped</td>
<td data-label="Total">€48.00</td>
</tr>
</tbody>
</table>
@media (width < 40rem) {
/* Hide the header row visually, keep it for screen readers */
.stack thead {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.stack, .stack caption, .stack tbody, .stack tr, .stack th, .stack td { display: block; }
.stack tr { border: 1px solid #d0d0d0; border-radius: 8px; padding: 12px; margin-bottom: 12px; }
.stack th[scope="row"] { font-size: 1.125rem; }
.stack td { display: flex; justify-content: space-between; gap: 16px; padding: 4px 0; }
.stack td::before { content: attr(data-label); font-weight: 600; }
}
Below 640px each row becomes a box: the order number as its title, then "Date … 3 Sep 2026", "Status … Shipped" and so on, with the label pulled from data-label by ::before. From 640px up it's an ordinary table again, with no styles to undo. width < 40rem is range syntax, supported in every major browser since 2023. In Tailwind, the building blocks are max-sm:sr-only on the thead, max-sm:block on the other table parts and max-sm:before:content-[attr(data-label)] on the cells.
What changing display does to accessibility
For years, putting display: block (or flex or grid) on table elements made some browsers stop exposing them as a table to screen readers. According to Adrian Roselli's testing, that's now fixed in Chrome (since version 80), Firefox and Safari (since 17), with one exception: display: contents can still remove table semantics (Tables, CSS display properties and ARIA). If you need to support older Safari, add the roles explicitly: role="table" on the table, rowgroup on thead and tbody, row on each tr, columnheader or rowheader on the th cells and cell on each td.
Two smaller things. Screen readers may read a label twice, once from the column header and once from ::before, which is a fair trade. And keep data-label identical to the header text, ideally by generating both from the same data.
3. Hide lower-priority columns
Sometimes a column is genuinely optional on a phone: an internal SKU, a "last edited" date, a column that's repeated on the detail page. Give every cell in that column a class, header included:
<th scope="col" class="col-optional">SKU</th>
<!-- … -->
<td class="col-optional">BK-2231</td>
@media (width < 48rem) {
.col-optional { display: none; }
}
In Tailwind: hidden md:table-cell on those cells. Be strict about what counts as optional. display: none removes the column for everyone on that screen, screen reader users included, and hiding the price column to make a table fit is deleting information, not designing. Combine this with the scroll wrapper when the remaining columns still don't fit.
4. Sticky first column
For wide data where each row has a name (a country, a product, a player), keep that name on screen while the numbers scroll past. Inside the scroll wrapper from pattern 1:
.table-scroll table {
border-collapse: separate;
border-spacing: 0;
}
.table-scroll th:first-child,
.table-scroll td:first-child {
position: sticky;
left: 0;
z-index: 1;
background: #fff; /* your page background */
}
- It sticks to the wrapper, because a sticky element sticks within its nearest scrolling ancestor (MDN: position).
- It needs a background, or the other cells show through as they slide underneath.
border-collapse: separatematters. Withcollapse, borders belong to the table rather than the cell, so they don't travel with a sticky cell (CSS Working Group issue). Put borders on the cells instead, for exampleborder-bottom.- Right-to-left pages should use
inset-inline-start: 0instead ofleft: 0. - Keep it narrow. On that 360px Galaxy the wrapper is 328px wide, so an 8rem (128px) first column leaves 200px for everything else. Let long names wrap rather than widening the column.
Want a sticky header row too? Because the wrapper scrolls sideways, it's also the scroll container for vertical sticking: top: 0 sticks to the top of the wrapper, not the page. It only works if the wrapper scrolls vertically as well:
.table-scroll.tall { max-height: 70svh; overflow: auto; }
.table-scroll.tall thead th { position: sticky; top: 0; z-index: 2; background: #fff; }
.table-scroll.tall thead th:first-child { z-index: 3; } /* the corner cell */
svh is the viewport height with the browser's toolbars showing (100vh on mobile explains why that's the one you want).
5. Turn comparison tables into lists
Pricing and feature comparisons put the options in columns and the features in rows. On a laptop that's great for scanning across. On a phone, people read one plan at a time, and a four-plan table forces them to scroll sideways and remember which column they were in. Give phones one list per plan instead:
<div class="plan-lists">
<section aria-labelledby="plan-basic">
<h3 id="plan-basic">Basic</h3>
<ul>
<li>1 project</li>
<li>Community support</li>
</ul>
</section>
<section aria-labelledby="plan-pro">
<h3 id="plan-pro">Pro</h3>
<ul>
<li>Unlimited projects</li>
<li>Custom domain</li>
<li>Email support</li>
</ul>
</section>
</div>
<div class="plan-table table-scroll" role="region" aria-labelledby="plans-caption" tabindex="0">
<table>
<caption id="plans-caption">Compare plans</caption>
<!-- … -->
</table>
</div>
.plan-table { display: none; }
@media (min-width: 48rem) {
.plan-lists { display: none; }
.plan-table { display: block; }
}
display: none hides the other version from screen readers too, so nobody hears both. Generate the lists and the table from the same data so they can't drift apart. For spec-style comparisons (storage, speed, weight), a <dl> with <dt> and <dd> pairs per product fits better than a <ul>. And if the table uses tick and cross icons, give them text alternatives like "Included" and "Not included".
Does your site fit?
Paste your address (or localhost:3000). doesitfit shows it on real screen sizes at once and tells you, bluntly, where it spills.
Free, no sign-up. Or ask Claude, ChatGPT or Codex to check it.
Tables are one of the most common reasons a page gets a SPILLING +Npx sticker in doesitfit: the number tells you how many pixels the table is pushing the page past the screen, and Show me where outlines it. Check at 360px (Galaxy S25), 375px (iPhone SE) and 402px (iPhone 17 Pro), then an iPad Air 11″ to make sure the full table comes back at 820px.
A prompt for your AI tool
The tables on my site make the page scroll sideways on phones. Fix each one without hiding overflow on the page. Keep real
markup with a
, and th elements with scope. Wrap wide data tables in with overflow-x: auto and a visible focus outline, plus a short "scroll sideways" hint on small screens. For tables where each row is a record (orders, bookings), show each row as a card below 40rem using data-label on each td and td::before { content: attr(data-label) }. For the pricing comparison, show one list per plan below 48rem and the table from 48rem up, generated from the same data. Don't hide any column unless I confirm it. The page itself must not scroll sideways at 360px or 320px wide.For other things AI tools get wrong on phones, see 10 responsive mistakes in AI-built websites, and for writing the phone version first so tables never start too wide, mobile-first CSS.
The short version
- A table can't shrink below its content, so on phones it pushes the whole page sideways.
- A table scrolling in its own box is fine (even for WCAG); the page scrolling is not.
- Data tables:
overflow-x: autowrapper withrole="region", a label andtabindex="0", plus a visible hint.- Records: stack rows as cards with
data-label. Current browsers keep table semantics; add ARIA roles for older Safari.- Hide columns only when they're truly optional. Pin the first column with
position: sticky; left: 0andborder-collapse: separate.- Comparisons: one list per plan on phones, the table on bigger screens.
Questions people ask
How do I make a table responsive on mobile?
Wrap it in a div with overflow-x: auto so only the table scrolls, or restyle it below a breakpoint: stack rows as cards, hide low-priority columns or show comparison data as one list per option. Pick the pattern by what the table is for.
Why does my table make the whole page scroll sideways?
By default a table can't get narrower than the content of its columns, so a table with several columns is often wider than a 360px phone. Without a scrolling wrapper, the page grows to fit it.
Why add tabindex="0" to a table's scroll wrapper?
So keyboard users can focus the box and scroll it with the arrow keys. Some browsers now do this automatically for scroll boxes with nothing focusable inside, but not all, so add it together with role="region" and a label.
Does display: block on a table break accessibility?
It used to. According to Adrian Roselli's testing, Chrome, Firefox and Safari 17 and later keep table semantics with display block, flex or grid, but display: contents can still remove them. Explicit ARIA table roles cover older browsers.
How do I make the first column of a table sticky?
Put the table in a wrapper with overflow-x: auto, and give the first cell of every row position: sticky, left: 0 and a background colour. Use border-collapse: separate with border-spacing: 0 so borders move with the sticky cells.
Keep reading
- 100vh on mobile is broken: use dvh, svh and lvh100vh hides your content behind Safari and Chrome toolbars on phones. Here's why, with real iPhone and Android numbers, and how dvh, svh and lvh fix it.Fixes · 8 min read
- Fixed headers and bottom bars on mobile Safari and ChromeSticky or fixed? How Safari and Chrome toolbars move on phones, keeping bottom bars clear of the home indicator and keyboard, and fixing hidden anchor links.Fixes · 9 min read
- How to fix horizontal scrolling on mobile (12 causes)Page wobbles sideways on phones? The 12 usual causes of horizontal scrolling on mobile, a fix for each, and a console snippet that finds the culprit fast.Fixes · 9 min read