I previously wrote about auditing every page at 320px, which was about finding the problems. This is about what we actually did to the tables, because tables were most of the findings and the fixes are not interchangeable.
The reason to rank them, rather than reaching for a horizontal scroller every time, is the failure mode at the bottom of the list.
Zero: why clipping is not a cosmetic bug
A paragraph that is cut off at the viewport edge looks cut off. A table of figures that is cut off produces something that looks complete and is wrong. If the right edge of a cell lands mid-digit, 12 renders as 1 with a sliver of ink after it, and a reader comparing columns reads a number that is not in the data. On pages where candidates compare figures across columns, that is the worst possible outcome, well below "ugly".
So "no horizontal overflow on the document" is the floor, not the goal. The goal is that every number is either fully visible or visibly unreachable.
One: make it fit, by letting it wrap
The first fix is almost always the boring one. Headings and row names are allowed to wrap, and below sm the panel and cells get tighter padding. That alone takes most four-column tables of figures down to a 320px screen whole.
What forced it in our case was single long words. At the looser desktop padding, cells containing "Community" and "September" pushed three columns of a four-column table past the edge. Nothing about the data was too wide, the padding was. Tighten it below sm and every column is comparable at a glance with no interaction at all, which is the thing every other option on this list gives up.
Two: a card per row
Where a table is reference material rather than something to compare across, the row becomes a card and the table disappears:
<div className="mt-4 space-y-3 md:hidden">
{BANDS.map((row) => (
<div key={row.period} className="rounded-lg border p-4">
<h3 className="mb-2 text-sm font-semibold">{row.period}</h3>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
<dt className="text-muted-foreground">Bands</dt>
{/* ... */}
</dl>
</div>
))}
</div>
The table itself gets hidden md:block. Two renderings of one array, so they cannot drift: if a row is added, both get it, and the compiler keeps the keys honest. A dl per card rather than two columns of text, because the cell headers are now labels and that is what a description list is.
The cost is real and worth naming: the DOM has the data twice, and every text search, print stylesheet and copy-paste behaves accordingly. Pay it for a table of five or six rows, not fifty.
Three: stack the rows as blocks
A gentler version for a three-column table inside prose. Below sm, each row renders as a small block with role="list" on the container, and the real table takes over from sm up:
<div role="list" className="not-prose my-6 space-y-3 sm:hidden" />
<div className="not-prose my-6 hidden overflow-x-auto rounded-lg border sm:block" />
Same idea as the cards, less chrome. The breakpoint is the interesting choice: sm and not md, because a three-column table does fit a 480px phone and swapping it for stacked blocks there is a downgrade. Pick the breakpoint from the table, not from a house default.
Four: pin the label and scroll inside the box
Some tables genuinely cannot fit. One of ours has six columns of cut scores. The row label is the only thing that makes a cell meaningful, so the first cell is pinned:
className="bg-card border-border sticky left-0 z-10 w-[8.5rem] border-r px-3 py-3 font-semibold sm:w-auto sm:border-r-0 sm:px-4"
Three things in there matter. sticky left-0 with a background, because a transparent sticky cell has the scrolling figures sliding visibly underneath it. A border-r that exists only below sm, because the divider is explaining a scroll that does not happen at wider widths. And a fixed w-[8.5rem] on phones only, so the pinned column cannot take half the viewport.
The scroller is the wrapper, not the page. The document still does not move sideways.
Five: measure before you advertise the scroll
If a table scrolls, the user has to know. We add a fade on the right edge and a line of text saying the table scrolls sideways, and both are conditional on measurement rather than on a breakpoint:
setScrolls(box.scrollWidth > box.clientWidth + 1);
setAtEnd(box.scrollLeft + box.clientWidth >= box.scrollWidth - 1);
Run in useLayoutEffect, re-measured on resize and scroll. A table that happens to fit on this phone at this font size gets no fade and no instruction, because an affordance pointing at nothing is worse than no affordance. The + 1 and - 1 are not superstition: scrollWidth and clientWidth are rounded integers while layout widths are fractional, so the exact comparison reports a one-pixel scroll on tables that do not scroll, and you get a permanent fade on a static table.
The fade also has a correctness job. It sits over the point where a column is half visible, which is exactly where the 12 rendered as 1 problem lives. Covering a partial digit with a gradient is the difference between "there is more to the right" and "the value is 1".
The one-character bug worth stealing
One table, on the rare over-wide case, had this wrapper:
- <div className="-mx-1 overflow-hidden">
+ <div className="-mx-1 overflow-x-auto">
overflow-hidden was there to stop a negative margin leaking, and it did. It also meant that the one table in the bank wide enough to need scrolling had its last column amputated with no indication whatsoever. overflow-hidden on a container that might hold something wider than itself is a silent data-loss bug, and it reads as perfectly reasonable CSS in review.
See it
Set your DevTools viewport to 320 by 568 and open these, then run document.documentElement.scrollWidth > window.innerWidth in the console on each. It should be false everywhere.
- cogniprep.app/games/faa-atsa: the band history table is hidden at this width and a card per period replaces it.
- cogniprep.app/games/pellet-b: same pattern, one card per sub-test.
- cogniprep.app/games/criticall: the cut score table is still a table, it scrolls inside its own box, and the module names stay pinned at the left edge while the figures move.
Three outcomes from one rule, which is that the number a reader sees has to be the number in the data.
Top comments (0)