Accessibility

How to Make Accessible Data Tables

Data tables only work for screen-reader users when headers are wired up correctly. Here is how to use caption, th scope, and headers/id, with code patterns.

StackOptic Research Team21 May 20268 min read
How to make accessible data tables with proper headers and captions

A data table is accessible when a screen-reader user can land on any cell and hear which headers it belongs to — so a number is announced as "Revenue, Q1, £40,000" rather than a context-free "40,000". Achieving that is mostly about using real table markup correctly: a <table> with <th> header cells, a scope attribute telling assistive technology whether each header governs a column or a row, a <caption> naming the table, and <thead>/<tbody> for structure. Complex tables with layered headers need the explicit headers/id association instead. And two rules bracket the rest: never use tables for visual layout, and make wide tables responsive without breaking the header relationships. This guide walks through each with copy-able code patterns.

It applies the WCAG standard to tabular content and is the kind of structural check you would perform in how to check if a website is accessible.

Why table accessibility is different

Sighted users read a table two-dimensionally: their eye traces up a column and along a row to give a cell meaning. A screen-reader user cannot do that visually — they move through the table cell by cell and depend on the markup to tell them, for each cell, which column header and row header apply. If the headers are not marked up as headers, or not associated with the data, the screen reader reads a flat stream of values with no context, and the table becomes a meaningless list of numbers. So table accessibility is fundamentally about encoding the header-to-data relationships that sighted users infer from position. Get those relationships right and the table works; get them wrong and the data is effectively lost to non-visual users.

Use real table markup

The first rule is to use genuine table elements for tabular data — <table>, <tr> for rows, <th> for header cells, and <td> for data cells. This sounds obvious, but plenty of "tables" on the web are stacks of <div>s styled with CSS grid, which look like tables but expose none of the relationships to assistive technology. A screen-reader user gets no table navigation, no header announcements, nothing. If your content is genuinely tabular — rows and columns of related data — use a real <table>. (Conversely, if it is not tabular, do not force it into a table; see layout below.)

Within the table, distinguish header cells from data cells. Headers are <th>; data is <td>. That distinction alone lets assistive technology begin treating the first row or column as labels rather than values — and it is the hook the scope attribute hangs on.

Caption and structure

Two elements give a table its title and skeleton:

  • <caption> is the table's accessible name, placed as the first child of the <table>. It tells everyone — including screen-reader users navigating a list of tables — what the table contains: <caption>Quarterly revenue by region (2025)</caption>. A caption is strongly recommended for every data table.
  • <thead>, <tbody> and <tfoot> group the header rows, the body rows and any footer (such as a totals row). They convey structure to assistive technology and make the table easier to style and to render correctly.

Simple tables: th and scope

For a simple table — one row of column headers, and optionally one column of row headers — the scope attribute is all you need to wire up the relationships. scope="col" on a <th> says "this header labels the whole column"; scope="row" says "this header labels the whole row." Here is the canonical pattern:

<table>
  <caption>Quarterly revenue by region (2025)</caption>
  <thead>
    <tr>
      <th scope="col">Region</th>
      <th scope="col">Q1</th>
      <th scope="col">Q2</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">North</th>
      <td>40,000</td>
      <td>52,000</td>
    </tr>
    <tr>
      <th scope="row">South</th>
      <td>31,000</td>
      <td>38,000</td>
    </tr>
  </tbody>
</table>

With this in place, when a screen-reader user moves to the cell containing 52,000, assistive technology can announce the associated headers — "North, Q2, 52,000" — giving the value its full context. Note the row headers are also <th> (with scope="row"), not <td>: the first cell of each body row is a label, and marking it as a header is what lets the screen reader read it alongside the data.

Complex tables: headers and id

Some tables are not simple. They have two levels of column headers (a span heading above sub-headings), multiple row-header columns, or cells that relate to several headers at once. In these cases scope is ambiguous — the browser cannot reliably infer which of several headers applies — so you associate cells explicitly using id and headers:

  • Give every header cell a unique id.
  • On each data cell, add a headers attribute listing the ids of all headers that apply to it, separated by spaces.
<table>
  <caption>Revenue by region and channel (2025)</caption>
  <tr>
    <td></td>
    <th id="online" scope="col">Online</th>
    <th id="retail" scope="col">Retail</th>
  </tr>
  <tr>
    <th id="north" scope="row">North</th>
    <td headers="north online">28,000</td>
    <td headers="north retail">12,000</td>
  </tr>
  <tr>
    <th id="south" scope="row">South</th>
    <td headers="south online">19,000</td>
    <td headers="south retail">12,000</td>
  </tr>
</table>

Now the cell reading 28,000 is explicitly tied to both "North" and "Online", and a screen reader can announce all the relevant headers. The headers/id method is more verbose but unambiguous, which is exactly what layered tables need. A useful rule of thumb: if a table is so complex that headers/id is getting unwieldy, consider splitting it into two or more simpler tables, which is usually easier for everyone to read, sighted or not.

A code-pattern reference

This table summarises which technique to use for which situation:

SituationPattern to use
Tabular data of any kindReal <table> with <tr>, <th>, <td> — never <div>s
Naming the table<caption> as the first child of <table>
Grouping rows<thead>, <tbody>, and <tfoot> for a totals/footer row
Column headers (simple table)<th scope="col">
Row headers (simple table)First cell of each row as <th scope="row">
Multiple header levels / cells with several headersUnique id on each header + headers="id1 id2" on each data cell
Layout / positioning (not data)CSS (flexbox/grid), not a table
Wide table on small screensKeep markup; allow horizontal scroll in a focusable, labelled container

Do not use tables for layout

Before CSS layout matured, developers used tables to position page elements. That practice is obsolete and is an accessibility problem: a screen reader interprets a layout table as a data table and announces rows, columns and cell relationships that mean nothing, cluttering and confusing the experience. WCAG expects structure to reflect meaning, so layout belongs to CSS (flexbox and grid), and <table> is reserved for actual tabular data. If for some reason a table element is genuinely unavoidable for non-data layout, role="presentation" will strip its table semantics so it is not announced as data — but the correct, future-proof answer is simply to use CSS. This is the table-specific case of the broader semantic-HTML principle from what ARIA is and when to use it.

Responsive tables without breaking accessibility

Wide tables are awkward on small screens, and the temptation is to restructure them with CSS so they "stack" on mobile. The danger is that some stacking techniques sever the header-to-cell relationships, leaving screen-reader users with values that have lost their context. The safest, most accessible approach is to keep the table markup and header associations intact and let the table scroll horizontally inside a container when the viewport is narrow, so every column remains reachable. Make that scroll container keyboard-focusable (so keyboard users can scroll it) and give it an accessible label, so it is announced as a scrollable region. If you do use a responsive pattern that visually collapses the table, verify with a screen reader that each value is still announced with its headers — because preserving meaning matters more than the visual format. Whatever you do, test the result the way real users will encounter it, as described in how to run an accessibility audit.

How screen readers navigate tables

It helps to know what good markup enables. In table-navigation mode, a screen reader lets the user move cell by cell with dedicated commands, and as they move it announces the headers associated with each cell plus the value. Users can jump to the next row or column, ask which headers apply, and orient themselves without seeing the grid. None of this works without correct <th>, scope or headers/id markup — with plain <td>s and no headers, the screen reader just reads values in sequence and the user has no way to know what any of them mean. This is precisely why a screen-reader pass is indispensable when checking a table, as covered in how to check if a website is accessible: the markup might look right but only listening confirms the relationships are announced.

Common data-table mistakes

The recurring failures: fake tables built from <div>s that expose no relationships; header cells marked as <td> instead of <th>, so they are read as data; missing scope on headers in tables that need it; no <caption>, leaving the table unnamed; using a table for layout, which confuses assistive technology; complex tables without headers/id, so layered headers are not associated; and responsive patterns that break header relationships. Each is fixable with the patterns above, and most are caught by combining an automated scan (which flags some structural issues) with a screen-reader pass (which reveals whether the data actually makes sense aloud).

The bottom line

Accessible data tables are about preserving the header-to-data relationships that sighted users read from position. Use real table markup, give the table a <caption> and <thead>/<tbody> structure, mark headers as <th> with scope="col"/scope="row" for simple tables, and switch to the explicit headers/id association for complex, multi-level ones. Never use tables for layout — that is CSS's job — and when tables must go responsive, keep the markup intact and prefer horizontal scrolling over restructuring that breaks the associations. Then confirm it with a screen reader, because correct-looking markup is not the same as correctly announced data. For the standard behind this, see what WCAG is and the difference between A, AA and AAA; for the wider context, what web accessibility is and why it matters.

Want to check whether your tables and the rest of your site pass a WCAG accessibility check, alongside SEO and performance? Analyse any URL with StackOptic — one report, free, no sign-up.

Frequently asked questions

What makes a data table accessible?

An accessible data table uses genuine table markup with header cells correctly associated to data cells, so a screen reader can announce, for each value, which row and column headers it belongs to. In practice that means using <table>, marking headers with <th> and a scope attribute, giving the table a <caption>, and using <thead> and <tbody> for structure. For complex tables with layered headers, you associate cells explicitly with the headers and id attributes. The goal is that a non-visual user can understand any cell in context.

What does the scope attribute do?

The scope attribute on a <th> tells assistive technology which cells that header applies to. scope='col' means the header labels its entire column; scope='row' means it labels its entire row. With scope set correctly, a screen reader can announce the appropriate header when the user moves to a data cell — for instance reading 'Revenue, Q1, £40,000' — so the value has context. For straightforward tables with one row of column headers and optionally one column of row headers, scope is the simplest correct approach.

Why should tables not be used for layout?

When you use a table purely to position elements visually, screen readers still interpret it as a data table and announce rows, columns and cell relationships that carry no real meaning, which is confusing and makes the content harder to follow. WCAG expects structure to reflect meaning, so layout should be done with CSS (flexbox or grid), and tables reserved for actual tabular data. If a table element is unavoidable for layout, role='presentation' can remove its semantics, but using CSS is the correct solution.

How do I make a complex table accessible?

A complex table has more than one level of row or column headers, or cells that relate to several headers, so the simple scope attribute is not sufficient. Give each header cell a unique id, then on every data cell add a headers attribute listing the ids of all the headers that apply to it, separated by spaces. This explicitly maps each value to its headers so a screen reader can announce them all. Where possible, though, consider splitting a very complex table into simpler ones.

How do you make a table responsive and accessible?

Keep the real table markup and header associations intact, and handle narrow screens with CSS rather than by restructuring the data. A common, accessible approach is to let the table scroll horizontally inside a container (with the scroll region made keyboard-focusable and labelled) so all columns remain reachable. Avoid techniques that visually collapse a table in ways that sever the header-to-cell relationships, because those can leave screen-reader users with values that no longer have any context.

Analyse any website with StackOptic

Get the full technology stack, performance, security and SEO report in seconds — free.

Analyse a website

Related articles