HTML tables have a reputation problem. Two decades ago developers used them for page layout, which broke accessibility and turned maintenance into a nightmare. The backlash pushed many engineers to avoid even for the one job it does well: rows and columns of related data.
A real table is the right element when the cell at row 4, column 2 means something. Product comparisons, pricing plans, schedules, KPI dashboards, sport standings. A
grid does not give screen readers any of that semantic information.The hard part is mobile. A six-column report that fits a 1440px monitor becomes a horizontal scroll on a 375px phone. That is a CSS problem, sometimes a layout problem, and occasionally a sign you should not be using a table at all.
Semantic Table Markup
A correct table uses every group element:
`html
| Product | Q1 | Q2 | Q3 |
|---|---|---|---|
| Widget A | 1,200 | 1,450 | 1,600 |
| Widget B | 800 | 920 | 1,100 |
| Total | 2,000 | 2,370 | 2,700 |
`: one-line description. Screen readers announce it before reading any rows.
, , : row groups. Browsers can scroll the body while keeping the header in place; screen readers use the grouping to navigate.
with scope: marks header cells with scope="col" or scope="row". Skip this and screen reader users have no way to know which header belongs to which cell.
If your data lives in a spreadsheet, dump it to CSV and convert with the JSON to CSV tool (it converts both directions). When the HTML is ready for production, run it through the HTML Minifier to drop whitespace.

Three Patterns That Survive Mobile
Pick the pattern that matches the data.
1. Horizontal scroll (simplest, no markup change):
`css
.table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
`
Wraps the table in a scrollable container. The structure stays intact. Good when every column carries equal weight.
2. Stacked rows (most readable on phones):
`css
@media (max-width: 768px) {
table, thead, tbody, th, td, tr { display: block; }
thead { display: none; }
td {
position: relative;
padding-left: 50%;
}
td::before {
content: attr(data-label);
position: absolute;
left: 0;
width: 45%;
font-weight: bold;
}
}
`
Each row turns into a card with labeled values. Requires data-label attributes on every . Best for 4 to 8 column tables where every value matters.
3. Column hiding (selective):
`css
@media (max-width: 768px) {
.priority-low { display: none; }
}
`
Hide secondary columns on mobile and add a "show all" toggle. Best for tables with a clear primary view and optional detail columns.
For very wide tables, combine: hide low-priority columns, then scroll the remainder.
Pick the pattern that matches the data.
CSS That Makes a Table Readable
Default tables look like a 1995 spreadsheet. A small ruleset fixes that:
`css
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #e5e7eb; }
thead th { background-color: #f9fafb; font-weight: 600; color: #374151; text-transform: uppercase; font-size: 0.75rem; letter-spacing: 0.05em; }
tbody tr:hover { background-color: #f3f4f6; }
tbody tr:nth-child(even) { background-color: #f9fafb; }
`
The rules that earn their keep:
border-collapse: collapseremoves the double border between cells. Set it on every table.- Zebra striping (
:nth-child(even)) helps eyes track a row across many columns. - Hover highlighting tells the user which row is under the cursor.
- Header styling (uppercase, smaller, tinted background) separates the header from the data.
- Alignment matters: left-align text, right-align numbers so digit places line up, center checkmarks and status icons.
When the stylesheet ships, run it through the CSS Minifier to drop comments and whitespace.
Accessibility Checklist
Tables are one of the trickiest elements for screen reader users. Run through this every time:
- Always include
: a one-line description of the table. Hide it visually with CSS if you must, but never drop it from the markup. withscopeon every header: column headers getscope="col", row headers getscope="row". Without scope, the row/column relationship is lost.- Do not use tables for layout: if there is no row-column meaning, use CSS Grid or Flexbox. Layout tables make screen readers announce nonsense.
- Avoid
colspanandrowspan: merged cells confuse keyboard navigation. When merging is unavoidable, useidandheadersto keep the relationship explicit. - Sortable headers: set
aria-sort="ascending","descending", or"none"on the active column header. - Stacked layout: keep
data-labeltext identical to the column header text so screen readers do not announce two different names for the same value. - Color is never the only signal: pair red/green with a
+or-sign, or with an icon, so color-blind users get the same information.

When Not to Use a Table
Some grids belong in a different element.
- Cards: if every row is a self-contained item (product, person, project) with images and actions, a card grid reads better than a table, especially on mobile.
- Definition lists: two-column "label / value" data fits a
and- better than a table, and is easier to style responsively.
with - Charts: if the question is "what is the trend?", a line or bar chart answers it faster than rows of numbers.
- Interactive data grids: filtering, grouping, pagination, virtualization, and cell editing all belong in a real grid component (AG Grid, TanStack Table). Trying to roll those on top of a styled
ends in pain.
The test: does the cell at row X, column Y mean something specific? If yes, table. If you only have a list of items with properties, cards or a
will do better.FAQ
Should I use a CSS framework for tables?
Tailwind and Bootstrap both ship table utilities that save time (Tailwind's divide-y and text-right are the two you will reach for most). For complex interactive tables, use a JS library like TanStack Table or AG Grid. For plain display tables, vanilla CSS is enough.
How do I make a table sortable?
For a static page, add click handlers on the elements that re-order the rows. For React or Vue, use TanStack Table for headless sort, filter, and pagination logic. For server-rendered pages, sort on the server and put the direction in a query parameter.
How many columns work on mobile?
Three or four columns fit a 375px screen without scrolling. Five to six need the stacked layout or horizontal scroll. Beyond six, switch to column hiding, expandable rows, or a card view.
Can I make table headers sticky?
Yes. position: sticky; top: 0; on the th elements keeps the header pinned while the body scrolls. Add a z-index and a solid background so cells do not bleed through.
### Should I use a CSS framework for tables.
JavaScript Obfuscation: What It Protects and What It Cannot
JavaScript obfuscation explained: how it works, the cost in file size and speed, the best tools, and what client-side code can never protect.
CSS clamp() for Responsive Type: One Line, No Media Queries
CSS clamp() for fluid responsive typography in one line. Syntax, a full type scale, real spacing examples, and the zoom rule that keeps it accessible.
CSS :has() Selector: The Parent Selector for CSS
CSS :has() selector lets you style parents based on children. See practical patterns, replace JavaScript logic, and build smarter responsive layouts.
Native CSS Nesting: What Changes When You Drop SCSS
Native CSS nesting works in every current browser. The syntax, the two places it differs from SCSS, a specificity trap SCSS never had, and when keeping SCSS is the right call.
