Français

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:

pricing-grid.html
<!-- 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:

pricing-table.html
<!-- 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):

complex-table.html
<!-- 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.

colspan.html
<!-- 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.

scroll-table.html
<!-- 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>
scroll-table.css
.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.

stack-table.css
@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;
  }
}
stack-table.html
<!-- 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 with scope or headers/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