Tables are not the enemy
Table of contents
The pendulum swung too far
In the 2000s, the web was built with tables. <table> for layout, <td> for
columns, <tr> nested inside <tr> — a semantic nightmare that worked
visually but told the browser nothing.
Then CSS arrived. Flexbox, Grid, media queries. The community banned layout
tables — rightly so. But the pendulum swung too far. Today, developers avoid
<table> even for tabular data. They build grids with <div> + CSS Grid,
card lists with flex, dashboards with components — and the screen reader sees
nothing but a <div> soup with no structure.
The problem was never the <table>. It was using it for layout. For data that
has rows, columns and headers, <table> remains the only correct answer — and
most sites get it wrong, or don't use it at all.
When to use a table
The rule is simple: if your data needs rows AND columns AND the headers give meaning to the cells, it's a table. If removing the headers makes the data incomprehensible, it's a table.
A pricing comparison across three plans? Table. An event calendar with dates, locations and times? Table. An order summary with product, quantity and price? Table.
A list of product cards on an e-commerce site? Not a table — it's a list of self-contained items, each with its own structure. A dashboard with stats blocks? Not a table — those are independent sections that happen to sit side by side.
The decisive test: does a cell need both its column header AND its row header to be understood? "€85.95" on its own means nothing. "€85.95" at the intersection of "Pro Plan" and "Monthly price" means everything. That's a table.
If the answer is no — if each item is self-sufficient — it's probably a list
(<ul>, <ol>) or a series of articles (<article>). And that's perfectly
fine.
Anatomy of an accessible table
Let's take a concrete case: a plan comparison. Here's what you commonly find:
<!-- Typical pricing comparison — looks like a table, isn't one -->
<section class="pricing">
<h2>Our plans</h2>
<ul class="pricing-grid">
<li class="plan">
<h3>Starter</h3>
<p class="price">€9<span>/mo</span></p>
<ul>
<li>1 user</li>
<li>Email support</li>
</ul>
</li>
<li class="plan">
<h3>Pro</h3>
<p class="price">€29<span>/mo</span></p>
<ul>
<li>10 users</li>
<li>Chat support</li>
</ul>
</li>
<li class="plan">
<h3>Enterprise</h3>
<p class="price">€99<span>/mo</span></p>
<ul>
<li>Unlimited users</li>
<li>Dedicated support</li>
</ul>
</li>
</ul>
</section>This isn't bad HTML. The <h3>s, <ul>s, <li>s are all there. The problem
is more subtle: this structure presents each plan as an independent block. A
screen reader reads "Starter, €9/mo, 1 user, Email support" then "Pro, €29/mo,
10 users, Chat support". It can't compare. There's no relationship between "€9"
and "€29" — no way to know they answer the same question (price) for different
columns (plans).
This is exactly the case where a table is the right answer:
<!-- The same data, with relationships -->
<table>
<caption>Plan comparison</caption>
<thead>
<tr>
<td></td>
<th scope="col">Starter</th>
<th scope="col">Pro</th>
<th scope="col">Enterprise</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Monthly price</th>
<td>€9</td>
<td>€29</td>
<td>€99</td>
</tr>
<tr>
<th scope="row">Users</th>
<td>1</td>
<td>10</td>
<td>Unlimited</td>
</tr>
<tr>
<th scope="row">Support</th>
<td>Email</td>
<td>Chat</td>
<td>Dedicated</td>
</tr>
</tbody>
</table>When a screen reader reaches the "€29" cell, it announces: "Pro, Monthly price, €29". The user can compare — the table creates the relationships that the card list cannot express.
What each element brings
<caption> — the table's title. It's the first thing the screen reader
announces. Without it, the user hears "table, 4 columns, 4 rows" but doesn't
know what it's about. With it, they hear "Plan comparison, table, 4 columns,
4 rows".
<thead> — separates headers from content. The browser knows this row
isn't data. Some screen readers repeat headers as the user navigates through
the table body — <thead> tells them what to repeat.
<th> — declares a cell as a header. Without <th>, the browser treats
all cells as equivalent data. The hierarchy disappears.
<th scope="col"> — this header applies to the entire column below.
"Starter" is the plan name for every row.
<th scope="row"> — this header applies to the entire row to the right.
"Monthly price" is the category for every cell in that row.
<tbody> — wraps the table's main content.
The empty <td> in the top-left corner — it's neither a column header nor
a row header, it's the intersection. An empty <td> is correct here, not a
<th>.
The scope attribute is the silent hero. Without it, the browser has to guess
which header applies to which cell. On a simple table, it often guesses right.
On a complex table, it gets it wrong. scope removes the ambiguity — one line
of code that changes everything for accessibility, and that almost nobody
writes.
Complex tables
Not all tables are as simple as a plan comparison. Some have multi-level
headers, merged cells, subgroups. That's where accessibility gets harder — and
where the temptation to rebuild everything in <div> returns.
Multiple headers with headers and id
When scope isn't enough — typically when headers stack across several
levels — you need to associate each cell with its headers explicitly. The
example below shows the mechanics on a deliberately simple case (where scope
alone would still suffice):
<!-- Budget breakdown with two header levels -->
<table>
<caption>Budget by department — Q1 2026</caption>
<thead>
<tr>
<td></td>
<th id="marketing" scope="col">Marketing</th>
<th id="engineering" scope="col">Engineering</th>
</tr>
</thead>
<tbody>
<tr>
<th id="salaries" scope="row">Salaries</th>
<td headers="marketing salaries">€120,000</td>
<td headers="engineering salaries">€340,000</td>
</tr>
<tr>
<th id="tools" scope="row">Tools</th>
<td headers="marketing tools">€15,000</td>
<td headers="engineering tools">€45,000</td>
</tr>
</tbody>
</table>The screen reader announces "Marketing, Salaries, €120,000". Each cell knows exactly which headers it belongs to — no guessing.
Merged cells — with caution
colspan and rowspan let you merge cells. It works, but every merge is a
risk: the screen reader has to recalculate the grid, and some do it poorly.
<!-- A merged header spanning two columns -->
<table>
<caption>Salary expenses, Q1 2026</caption>
<colgroup><col></colgroup>
<colgroup span="2"></colgroup>
<thead>
<tr>
<td></td>
<th colspan="2" scope="colgroup" id="q1">Q1 2026</th>
</tr>
<tr>
<td></td>
<th scope="col" id="jan">January</th>
<th scope="col" id="feb">February</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row" id="sal">Salaries</th>
<td headers="q1 jan sal">€40,000</td>
<td headers="q1 feb sal">€41,000</td>
</tr>
</tbody>
</table>Two details almost everyone forgets: scope="colgroup" is only valid if the
table declares the matching <colgroup> elements, and the ids only serve a
purpose if cells reference them via headers.
It works. But ask yourself: would two simple tables be clearer than one merged table? A table you have to explain is a table that's too complex. The W3C recommends it explicitly: restructure the information to reduce complexity rather than over-mark-up a convoluted table.
The golden rule
If you need headers + id on every cell, your table is complex. If you need
colspan + rowspan + headers + id, your table is probably too complex.
Simplify the data first. The markup will follow.
Tables and mobile
This is THE reason developers abandon <table>. A 6-column table on a
375-pixel screen overflows. And the instinctive response — rebuild everything
in <div> + flexbox — sacrifices accessibility to solve a layout problem.
There are solutions that keep the <table> intact.
Horizontal scroll
The simplest and often the best: the table stays a table, inside a container that scrolls.
Why not put the scroll directly on the <table>? Because a table expands to
fit its content. For overflow to trigger scrolling, you'd have to constrain
the <table>'s width by changing its display, and changing the display of
a table element destroys its semantics for screen readers (we come back to
this below with stacking). A container is necessary, and <figure> fills that
role. If the table is a truly independent
piece of content (a report, a dataset), <article> is also a valid choice.
<!-- figure wraps the table — provides scroll and focus -->
<figure class="table-scroll" tabindex="0" role="region"
aria-labelledby="caption-pricing">
<table>
<caption id="caption-pricing">Plan comparison</caption>
<!-- ... -->
</table>
</figure>.table-scroll {
overflow-x: auto;
}
.table-scroll:focus-visible {
outline: var(--outline-width) solid currentColor;
outline-offset: var(--outline-offset);
}The tabindex="0" makes the container keyboard-focusable — the user can
scroll with arrow keys. The role="region" and the aria-labelledby pointed
at the table's <caption> make the screen reader announce "region, Plan
comparison" when focus arrives: the user knows what the scrollable area
contains. Without that, the keyboard user doesn't even know there's content
hidden to the right.
Stacking on mobile
For tables where each row is an "object" (a product, a user, a transaction), you can stack cells vertically on mobile. Each row becomes a card, each cell displays its header next to its value.
@media (max-width: 40em) {
table, thead, tbody, tr, th, td {
display: block;
}
thead {
/* Visually hidden but accessible */
position: absolute;
width: 1px;
height: 1px;
clip: rect(1px, 1px, 1px, 1px);
}
td::before {
content: attr(data-label);
font-weight: 650;
display: block;
}
}<!-- Each cell carries its label, and the ARIA roles
restore the semantics that display: block destroys -->
<table role="table">
<thead role="rowgroup">
<tr role="row">
<th role="columnheader" scope="col">Plan</th>
<!-- ... -->
</tr>
</thead>
<tbody role="rowgroup">
<tr role="row">
<th role="rowheader" scope="row">Pro Plan</th>
<td role="cell" data-label="Monthly price">€29</td>
<td role="cell" data-label="Users">10</td>
<td role="cell" data-label="Support">Chat</td>
</tr>
</tbody>
</table>On mobile, each cell displays "Monthly price" above "€29". But this pattern
has a trap, and it's a vicious one: changing the display of a table element
removes its semantics from the accessibility tree in most browsers. Without a
countermeasure, there is no table, no rows, no headers left for the screen
reader: exactly the soup this article denounces, served by CSS instead of
HTML. That's what the redundant role attributes above are for: they
explicitly redeclare what display: block destroyed, and the visually hidden
<thead> becomes genuinely accessible again.
This pattern has two limitations. It duplicates information (headers exist in
both the <thead> and the data-label attributes). If headers change, you
need to update both. And the ARIA roles must be maintained on every element:
forget one role="row" and the grid silently falls apart. If that maintenance
feels fragile to you, that's a signal: horizontal scroll remains the safest
option, because it touches nothing.
What not to do
Rebuild the table as <div>s past a certain breakpoint. Some JavaScript
frameworks do this: they detect screen size and replace the <table> with a
card list in <div>s. The result is an accessible table on desktop and a
<div> soup on mobile — exactly where accessibility matters most, since
mobile screen reader users are
growing fast.
What it costs to do otherwise
The most common reflex: replace the <table> with a CSS grid. <div>s with
display: grid, <span>s for values, maybe <h3>s for column headers.
Visually identical. Semantically, everything is lost.
For the screen reader
A <table> announces "table, 4 columns, 4 rows". The user knows immediately
what they're dealing with. They navigate with arrow keys — right for the next
cell, down for the next row. With each move, the screen reader repeats the
column header and row header.
A <div> grid announces... nothing. The user hears a stream of values —
"Starter", "€9", "1", "Email", "Pro", "€29"... — with no idea what goes with
what. They can't navigate by cell. They can't compare. They're reading a
linear stream of text, not a table.
For SEO
SEO: Google understands the structure of a <table>. The <th> headers tell it
what each column represents. The data in <td> cells is associated with their
headers. This structure feeds featured snippets and rich results — Google can
extract "the Pro plan costs €29/month" directly from the markup.
A <div> grid? Google sees text. It can guess, but it doesn't know.
For GEO
GEO: LLMs love tables. A well-marked-up
<table> is a block of structured data they can cite as-is — columns, rows,
relationships. It's exactly the format they look for to answer "what's the
price of the Pro plan?".
I go into detail in How GEO forces web accessibility.
The overall criteria
WCAG governs table accessibility through several success criteria:
- 1.3.1 Info and Relationships (A) — information, structure, and relationships conveyed through presentation can be programmatically determined. This is the core criterion: every table must have a
<caption>,<th>headers withscopeorheaders/id, and correct association between data cells and their headers. - 1.3.2 Meaningful Sequence (A) — the reading order must be correct and meaningful. Layout tables must not disrupt reading and navigation order.
In practice, meeting these criteria comes down to what this article describes:
a relevant <caption>, <th> elements with scope or headers, correct
association between cells and their headers, and never using a <table> for
layout.
Sources
- Tables Tutorial — W3C WAI
- Multi-level Headers — W3C WAI
- Tables, CSS Display Properties, and ARIA — Adrian Roselli
- Screen Reader User Survey #10 — WebAIM, 2024
- The
<table>element — WHATWG - The
<table>element — MDN