Making complex tables accessible

HTML tables have a bad reputation. People often see them as bad for accessibility, bad practice, and outdated.

This reputation is only fair when we use tables for layout. Most modern websites don’t do this, but most emails still do. Tables have semantic value (meaning) and functional behaviour. Tables work well for data in rows and columns. They don’t work well for layout.

You can use role="presentation" to remove a table’s semantic value and functional behaviour. Even then, the content order in the DOM still needs to make sense.

Let’s see an example of a table with inventory information for a small bakery:

Weekly inventory by category
CategoryItemPricingStockStatus
Unit CostRetail PriceIn StockReorder Level
BreadsBaguette$0.65$3.254220OK
Sourdough Loaf$0.90$4.751518Low
Rye Loaf$0.85$4.502715OK
PastriesButter Croissant$0.55$2.956025OK
Cherry Danish$0.70$3.50812Low
CakesCarrot Cake Slice$1.10$4.951810OK
Cheesecake Slice$1.25$5.252210OK
Totals192 units110 units2 items low

It doesn’t look bad, does it? CSS makes the parts easy to tell apart, and the layout makes sense.

That’s the main problem. Important information like relationships and structure relies only on visual styles and position. People using assistive technology can’t access that information.

At the end of this article, you’ll find the same table with everything the first version is missing.

Let’s go step by step.

Header cells

This is the easiest change to make. All cells in the first table use the data cell tag, <td>. But HTML also has a header cell tag, <th>, for cells that act as headers.

Use the scope attribute to mark a header cell as a column header (scope="col") or a row header (scope="row"). This gives you headers across the top for each column, and a header at the start of each row.

Row groups

A table can have both column groups and row groups. The scope attribute also accepts colgroup and rowgroup values for these.

There are 3 main row groups: the table head (<thead>), the table body (<tbody>), and the table footer (<tfoot>). These are especially useful for large tables.

In our example:

  • The top headers go in the table head.
  • The product data goes in the table body.
  • The totals go in the table footer.

This makes it easier to navigate between the main parts.

Caption

This part matters. A caption describes the table’s content so people can decide whether they need or want to read the data.

You have a few options here. You can use:

  • A caption (or <figcaption> if the table sits inside a <figure>)
  • Text, like a paragraph, placed before the table
  • Text in another element, linked with the aria-describedby attribute

These methods work equally well. Use only one.

One advantage of <caption>: you can nest a <details> element inside it, with extra information in a <summary> dropdown, or add text that’s visually hidden but still readable by screen readers. I used this second approach in the example at the end of the article. It gives everyone a short, visible label, and gives screen-reader users a longer text with more context.

Note: the summary attribute on the <table> element is obsolete in the HTML Living Standard. Don’t use it.

Multi-level headers

This part makes the biggest difference for people using assistive technology, especially in complex tables.

In our example, both columns and rows have multiple header levels. This can look tricky, but the fix is simple.

First, add a unique id attribute to each header cell. Then add a headers attribute to each data cell. List every header cell it relates to — both column and row headers, at every level.

For example, the cell with the baguette’s retail price includes: headers="row-breads row-baguette col-pricing col-retail". This lets screen readers announce every related header for that cell.

Conclusion

Accessible tables aren’t difficult to build. The markup can get verbose, since you’re linking many cells to many headers. But it’s simple to implement. And it turns a slow, frustrating experience — scrolling through endless data with no context — into a clear, usable source of information.

Weekly inventory by category Lists each bakery item’s unit cost, retail price, current stock, and reorder level, grouped by product category, with totals in the final row.
CategoryItemPricingStockStatus
Unit CostRetail PriceIn StockReorder Level
BreadsBaguette$0.65$3.254220OK
Sourdough Loaf$0.90$4.751518Low
Rye Loaf$0.85$4.502715OK
PastriesButter Croissant$0.55$2.956025OK
Cherry Danish$0.70$3.50812Low
CakesCarrot Cake Slice$1.10$4.951810OK
Cheesecake Slice$1.25$5.252210OK
Totals192 units110 units2 items low