How to Build an Accessible Data Table

A table works for a sighted reader because the eye can move in two dimensions. Everything about accessible tables is reconstructing that ability.

How to Build an Accessible Data Table — Troiana insight cover

In short

An accessible table uses real table markup with header cells marked as headers and given a scope, a caption describing what the table contains, and no merged or nested structure where it can be avoided. Do not use tables for layout, and do not replace a table with divs — screen readers rely on table semantics to let people navigate cell by cell with the headers announced.

Use real table markup

If data has rows and columns, it is a table. Use <table>.

Building one from <div>s destroys the semantics screen readers depend on. In a real table, a user can move cell by cell and hear the relevant row and column headers announced with each value. In a grid of divs, they hear a list of unrelated numbers.

That announcement is the entire accessibility mechanism for tables, and it comes free with correct markup.

Headers and scope

Header cells are <th>, not styled <td>.

Add scope="col" for column headers and scope="row" for row headers. This tells assistive technology which cells each header describes — usually inferred correctly, but explicit is reliable and costs nothing.

Most data tables have both: a header row naming the columns, and a first cell in each row naming what that row is. The row header is the one people forget, and it is what makes a value meaningful when read aloud.

Give it a caption

<caption> describes what the table contains, and is announced when a screen reader user encounters it. It lets someone decide whether to explore the table or move past it.

It is also useful visually — a table with a clear title is easier to scan — so this is one of the cases where the accessible choice improves the design rather than constraining it.

If you have supporting notes, put them near the table and reference them in the caption rather than burying them in a footnote nobody finds.

Keep the structure simple

Avoid merged cells. colspan and rowspan make a table much harder to navigate non-visually, because the relationship between a cell and its headers becomes ambiguous.

Avoid nested tables entirely. There is almost always a better structure.

Avoid empty header cells. The top-left cell of a table with both row and column headers is often left blank; leaving it empty is acceptable, but leaving other headers empty is disorienting.

If your data genuinely needs a complex structure, consider whether it should be two simpler tables. It usually should.

Never use tables for layout

A layout table announces structure that does not exist, and it makes content read in an order that may not match the visual arrangement.

Use CSS Grid or Flexbox for layout. This has been settled for a long time, and the pattern mostly survives now in email templates, where it remains genuinely necessary.

Responsive tables

This is where tables and small screens conflict, and where accessibility is most often lost.

Horizontal scrolling is the simplest honest answer. Wrap the table in a container with overflow-x: auto, and make that container focusable with a label so keyboard users can scroll it. The table stays a table, and the semantics survive.

Stacking each row into a card works for simple tables, but requires care: the header must be repeated for each value, or the numbers lose their meaning. Doing this with CSS pseudo-elements often hides those labels from assistive technology.

Hiding columns loses data, and it is rarely obvious to the user that something was removed. If you do it, say so and give a way to see everything.

The honest position: a large table on a small screen is a hard problem, and horizontal scroll is usually the least bad answer rather than a failure of imagination.

Sorting and interaction

If columns are sortable, the sort control must be a real <button> inside the <th>, and the current state must be conveyed with aria-sort so it is announced rather than only shown with an arrow.

When the sort changes, announce it in a live region. Otherwise a screen reader user presses a control and receives no feedback that anything happened.

The same applies to filtering: announce how many rows now match.

Numbers and alignment

Not strictly accessibility, but it affects everyone reading data.

Right-align numbers so digits line up by place value, and use tabular figures so columns of numbers align properly. Keep units in the header rather than repeating them in every cell.

And be consistent about how you represent missing data. An empty cell is ambiguous — is it zero, unknown, or not applicable? A dash with a note in the caption resolves it.

A quick check

Does it use real table markup? Are header cells <th> with a scope? Is there a caption? Are merged cells avoided? Does it scroll rather than break on a phone? Can a keyboard user reach and scroll it? Are sort controls real buttons with state announced?

Most tables fail on scope and caption, and both are one line each.

If you are building an interface where people read a lot of data, book a call.

Common questions

How do you make a data table accessible?

Use real table markup with header cells as th elements carrying scope="col" or scope="row", add a caption describing the contents, and avoid merged or nested structures. Screen readers use those semantics to announce the relevant headers as someone moves cell by cell, which is the whole mechanism.

Can I build a table with divs instead?

No — it destroys the semantics screen readers rely on. In a real table a user hears the row and column headers announced with each value; in a grid of divs they hear a list of unrelated numbers with no way to know what any of them refer to.

How should tables work on mobile?

Horizontal scrolling inside a focusable, labelled container is usually the least bad answer, because the table stays a table and the semantics survive. Stacking rows into cards works for simple tables but requires repeating headers per value. Hiding columns loses data without telling the user.

Should I use scope on table headers?

Yes. Assistive technology usually infers the relationship correctly, but scope="col" and scope="row" make it explicit and reliable at no cost. Row headers in particular are commonly forgotten, and they are what makes a value meaningful when read aloud.

How do I make sortable columns accessible?

Put a real button inside the th rather than attaching a click handler to the cell, convey the current state with aria-sort so it is announced rather than only shown as an arrow, and announce the change in a live region — otherwise a screen reader user gets no feedback that anything happened.

Have something worth building right?