Skip to main content
All articles

October 26, 2026·8 min read

How to Fix Complex Table Accessibility Headers

For complex table accessibility headers, learn how colspan, rowspan and headers with id link merged cells, and when splitting the table gives a clearer result.

accessibilitywcagtablestable-headersscreen-readerhtmlmerged-cells

Merged cells break the simple grid that screen readers rely on, and that is where complex table accessibility headers matter most. A sighted reader sees that one header sits over two columns, or that one label runs down two rows. A screen reader reads one cell at a time, so it needs the code to say which headers belong to each data cell. colspan and rowspan change the shape of a cell. They do not, on their own, explain that shape to assistive technology.

Key takeaways

  • colspan stretches a cell across columns. rowspan stretches a cell down rows. Neither one creates a header link by itself.
  • A header over a group of columns needs scope="colgroup" and a colgroup element. A header over a group of rows needs scope="rowgroup" and a row group element such as tbody.
  • Use th for every header cell and td for every data cell, even when a header spans more than one row or column.
  • When one data cell answers to three or more headers, or headers change part way through the table, give each header an id and list those ids in the headers attribute on the data cell.
  • Split the table first if the split keeps the meaning intact.

What merged cells change for a screen reader

A plain table has one row header and one column header for each data cell. A merged header breaks that neat grid: one header now covers a set of cells, and a data cell may need a group header plus a column header plus a row header before its value makes sense.

WCAG 2.2 success criterion 1.3.1, Info and Relationships, asks for information shown by layout to be available in the code or in text as well. Bold text, a wider cell, or a shaded header row does not carry that link.

Start with a short caption that names the table in plain words, for example "Sales in the first half of the year". A caption gives a table its accessible name, so a person moving between tables on a page can tell which one they have landed in.

Step by step: build a merged header table

Count the columns first, then decide which headers span more than one column or row, then write the groups before you write the rows. In the example below, "First half" covers two columns, while "Product" and "Total" each cover one column across two header rows, so the grid has four columns in total.

Define the column groups at the start of the table. A colgroup element defines a group of columns, with a span count for the number of columns in the group. Do this before any row, because a scope="colgroup" header needs a group that already exists in the code.

Now write the header rows. Give the spanning header colspan="2" and scope="colgroup". Give a header that runs down two header rows rowspan="2" and scope="col", since it still labels one column. Give each second level header scope="col". In the data rows, each product name is a th with scope="row", and the values are plain td cells.

<table>
  <caption>Sales in the first half of the year</caption>
  <colgroup><col></colgroup>
  <colgroup span="2"></colgroup>
  <colgroup><col></colgroup>
  <tr>
    <th rowspan="2" scope="col">Product</th>
    <th colspan="2" scope="colgroup">First half</th>
    <th rowspan="2" scope="col">Total</th>
  </tr>
  <tr>
    <th scope="col">Jan to Mar</th>
    <th scope="col">Apr to Jun</th>
  </tr>
  <tr>
    <th scope="row">Notebook</th>
    <td>40</td>
    <td>55</td>
    <td>95</td>
  </tr>
</table>

Check that code: the colgroup elements add up to the four columns, "First half" points to the group, and "Product" still points to one column, so it keeps scope="col".

Down the rows, a header spanning two or more rows needs rowspan plus scope="rowgroup", and the rows it covers need to sit inside a row group element such as tbody. The number of columns or rows a header covers decides its scope value, not its position.

When scope is not enough: use headers and id

Scope works when each data cell has a clear row direction and a clear column direction. It stops working when a cell needs more than one header from the same direction, or when headers repeat or change part way through the table.

In that case, give each header cell a unique id, and put a headers attribute on each data cell that lists the ids of every header that applies to that cell, separated by spaces. Keep every id unique across the whole page, not only inside the table, because a duplicate id can point a cell at the wrong header. Here is the "Notebook" row from the example above in this explicit form:

<tr>
  <th id="notebook" scope="row">Notebook</th>
  <td headers="notebook first-half jan-mar">40</td>
  <td headers="notebook first-half apr-jun">55</td>
  <td headers="notebook total">95</td>
</tr>

This form is longer, and that length is a signal. If a table needs a long headers list on every cell, test a split before you finish the explicit version. Keep the form in good repair: if a column is added or removed, check every headers list in that column. A scan can flag a headers value that points outside the table, but it cannot judge whether the id points at the right header.

When complex table accessibility headers call for a split

Split a table when each part can stand on its own with a clear caption and plain scope values, and when readers do not need to compare values across the parts in the same grid. The WAI multi-level tutorial and MDN both give this as the first move to consider, before reaching for headers and id.

Use this split test:

  1. Give each part its own caption. If you cannot write a clear caption for a part on its own, the split may have cut the meaning in half.
  2. Check the headers in each part. If each part now needs only scope="col" and scope="row", the split has done its job.
  3. Check the comparison the reader needs. If the main task is to compare values across the parts, keep the table whole and use headers and id.
  4. Check for headers that change halfway down the table. That is a strong sign that two tables are hiding inside one.

For the sales example, a split could give one table for the first half of the year and one for the second half. Do not split just to avoid writing header code, and keep the table whole when the question is a direct comparison across the groups. After any change, run a manual check: open the source, read the header rows in order, then move through the table cell by cell and listen for the headers announced with each value. A scan is a starting point, and this quick manual check is part of good practice.

How Axeazy helps with this exact issue

Table header structure is one of the 14 fix categories Axeazy works on, and a scan can flag tables where header markup is missing or wrong. That flag starts the work on a merged table, because deciding which header belongs to which cell needs a person who understands the data. For a site with a GitHub repository, code level fixes arrive as a pull request with a visible diff for the owner or developer to review and merge. The capabilities page sets out the honest scope, including what still needs a human check.

For background, read the scope attribute guide and the table header guide on our blog.

The WordPress free plugin applies three instant fixes at render time, for the skip link, page language and alt text fallback, and those three do not touch table markup, so table work on WordPress also stays a manual review task. For a quick read on your own site, try Axeazy's free 60 second scan , which needs no signup and reports table issues on your homepage.

Last checked: October 26, 2026.

FAQ

Should I use colspan or rowspan on a data cell?

Use them on a data cell only when one value truly covers more than one column or row, such as a total that spans two columns. If the value belongs to one cell only, keep it in one cell and let the headers do the labelling work.

What is the difference between scope colgroup and headers with id?

Scope colgroup labels a whole group of columns defined with a colgroup element, and it works when each cell needs one group header plus one column header. Headers with id gives a cell by cell list, for cells that need several headers or where headers change part way through the table.

Can I put headers on a th element as well as a td element?

Yes. A header cell can carry a headers attribute that points to a higher level header, for example a second level header pointing to its group header. Keep the list short and check that every id in it exists on the page.

When should I split a table instead of adding headers and id?

Split when each part answers its own question with a clear caption and plain scope values, and when readers do not need to compare values across the parts in one grid. Keep the table whole, with headers and id, when the main task is a direct comparison across the groups.

Sources

Check your own site

See what is actually wrong with your site.

Free, 60 second WCAG 2.2 AA scan. No credit card required.

Scope of every report we ship: SCOPE LIMITATION: This report documents violations identified by automated WCAG 2.2 AA scanning using axe-core v4.11.0. Automated tools cannot test every WCAG criterion; many criteria require manual review. This report does not constitute legal advice, guarantee ADA compliance, or protect against legal action. ADA compliance is a legal determination made by courts. For full WCAG conformance assessment, engage an IAAP-certified accessibility professional.