Topic 15.3
The Data-Table Twin
In one line
A real HTML table beside every chart holds the same numbers, and it works for screen readers, for keyboards, for people who want exact values and for anyone who wants to copy the data. Sort it, cap it, and summarise big data first.
Think of it like this
A weather forecast on television. The map with colours is the chart. The strip underneath that says "Monday 14, Tuesday 17, Wednesday 12" is the table. Some people look at the map, some read the strip, and the strip is the only one that gives exact numbers.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- table twin
- A real HTML table with the same numbers as a chart, next to it.
- caption
- The table's title, read when a screen reader reaches the table.
- scope
- An attribute that says a heading cell is for a column (col) or a row (row).
- aria-sort
- An attribute on a heading cell: ascending, descending or none, so a screen reader can say how the table is sorted.
- details and summary
- HTML elements for a part that opens and closes with a click or the keyboard, with no script.
- live region
- An element a screen reader watches: when its text changes, the new text is spoken without moving focus.
- summarise
- Replace many rows with fewer rows of totals, such as one row per month.
Step by step
01Pick the elements
<table>, <caption>, a <thead> row of <th scope="col">, and a <tbody> where the first cell of each row is <th scope="row">. That is everything a screen reader needs to say "Tue, Rides, 3,992".
02Wrap it in a details
The summary line says what is inside and how big it is: "Show the numbers as a table (12 rows)". It is reachable by Tab, opens with Enter or Space, and costs one line.
03Make the headings sort
Each heading holds a button. A click sorts a copy of the rows and sets aria-sort on that heading. The helper says the new order out loud with announce (Topic 15.4 builds it).
04Handle the odd cells
The first example sends in a day with no number, sorts by number twice and by text once, and then sends an empty list. Watch the Console: the dash sorts last both ways, and the chart's own array keeps its order.
05Cap or summarise big data
The second example reads the real 365 days. It caps one table at 20 rows with a sentence about the rest, and shows the other as 12 monthly rows.
Try it yourself
- 1
Raise the limit above the number of rows
In the second example, change
limit: 20tolimit: 400. The Console printsOption 1 rows in the table: 365andOption 1 note:with nothing after it: when every row is shown there is nothing to say. - 2
Put text where a number belongs
In the first example, change Wednesday's
rides: nulltorides: "n/a". The cell still shows a dash (the formatter refuses non-numbers), but sorted biggest first Wednesday now comes first:Wed– | Tue3,992 | Thu3,742 | Mon2,979. A text value in a number column sorts as text. The dash hid the cause, which is why the column should be cleaned before it reaches the table.
Code & diagrams
tableTwin is src/parts/table-twin.js. announce() writes into one live region (role=status), which the last line reads back after 300 ms. Its output stays in UTC-independent text, so it is the same everywhere.
Preview
Console
Nothing logged yet.Expected console output
start : Mon2,979 | Tue3,992 | Wed– | Thu3,742 | aria-sort: none
Rides once : Tue3,992 | Thu3,742 | Mon2,979 | Wed– | aria-sort: descending
Rides twice: Mon2,979 | Thu3,742 | Tue3,992 | Wed– | aria-sort: ascending
Day : Mon2,979 | Thu3,742 | Tue3,992 | Wed– | aria-sort: ascending
Summary : Show the numbers as a table (4 rows)
The chart's own array is unchanged: Mon,Tue,Wed,Thu
Empty : Show the numbers as a table (no rows) | table hidden: true | There are no rows to show.
Spoken : Table sorted by Day, lowest first.Capping keeps every day reachable by sorting. Summarising answers the same question the chart answers. Both say what they did.
Preview
Console
Nothing logged yet.Expected console output
Option 1 rows in the table: 20
Option 1 note: Showing the first 20 of 365 rows. Sort a column to bring others to the top.
Option 2 rows: 12
Option 2 first row: 2025-01 | 43,439 | 2025-01-21 (1,628)
Option 2 note: ""Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Sort the chart's own array
In table-twin.js (inlined in the first example), change const list = sort ? [...current].sort(compare) : current; to const list = sort ? current.sort(compare) : current;.
Myth vs fact
Myth
A table is only for people who cannot see.
Fact
It is also for people who want an exact value, want to copy the data, use a keyboard, or have the chart blocked by a slow connection.
Myth
Hiding the table with display:none keeps the page tidy and still helps a screen reader.
Fact
display:none removes it from the accessibility tree too. Use a details element, or a visually hidden table that is still visible on focus.
Myth
Every data point should be in the table.
Fact
For big data, a summary is the honest twin: it holds what the chart shows. Say how many rows were left out.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
A table that can scroll sideways needs a keyboard route: the wrapper has
tabindex="0",role="region"and a label, so the keyboard can reach and scroll it (the helper does this). - ▸
Large live tables should not be rebuilt whole on every update.
body.replaceChildren(...)is fine for hundreds of rows; for thousands, render a window of rows (/reactTopic 16.9 teaches virtualisation, and Topic 12.4 shows less data in the first place) and tell the screen reader how many exist witharia-rowcountandaria-rowindex. - ▸
Do not use
role="grid"for a read-only table. A grid promises cell-by-cell keyboard navigation and a plain table does not need it.
Remember this
- 1
The problem this solves: a description gives the shape, and a person still wants a number: "what were rides on 14 August?". A chart cannot give a precise value to someone who cannot see it, and does not give one to someone who can, without hovering. A table gives every value, in a form every tool already understands.
- 2
Use real table elements.
<table>with a<caption>,<th scope="col">for column headings and<th scope="row">for the first cell of each row. The browser then tells a screen reader which heading goes with each cell, so a person can move across a row and hear the column names. A grid of<div>s styled like a table tells it nothing (WCAG 1.3.1 Info and Relationships). - 3
Visible, or one click away. Hiding the table with
display: nonehides it from everyone. Hiding it visually only (a clip rule) leaves it in the tree but invisible to a sighted keyboard user, who then tabs into something they cannot see. A<details>with a summary ("Show the numbers as a table (12 rows)") is visible, reachable with Tab, announces whether it is open, and costs one line of space. - 4
Sort with real buttons. A sortable heading holds a
<button>, and the<th>carriesaria-sort="ascending"or"descending"while it is the sort. Pressing it re-renders the table and says what happened in a live region ("Table sorted by Rides, highest first"). Sort a copy: sorting the chart's own array would reorder the chart (see Break it). - 5
Missing, text and mixed values. A null or NaN cell shows a dash, never the word "NaN", and sorts last whichever way the column is sorted. Sorting a text column such as the day of the week sorts the text (
Mon, Thu, Tue, Wed), not the week: if order matters, sort by a number and show the text. Mixed numbers and strings in one column sort as text: clean the column (Topic 4.4) before it reaches a table. - 6
Big data: cap it or summarise it. A table of 365 rows is a wall; 10,000 trips is a hang. Either cap the rows and say so ("Showing the first 20 of 365 rows"), or summarise first (one row per month with the total and the busiest day). Summaries are usually the better answer, because they are what the chart shows. The second example does both on the real
daily.csv.
Explain it without notes
Why a real table and not styled divs?
What is the difference between display:none and a details element for a table twin?
How should a sortable column be built, and what must it tell a screen reader?
What are the two honest ways to handle a 365-row twin?
Practice
Write the HTML for a table twin of two stations: Quay 7,078 and Lantern 7,260, with the right caption and scopes.
A column mixes numbers and the text n/a. Write a one-line clean step before the table.
Trade-offs
- ↔
A table at full length is complete and heavy. A summary is light and answers fewer questions. Offer the summary open and the full rows on request when people ask for them.
Done when you can
I can build a table with a caption, column headings, row headings and scope.
I put the table in a details element with a summary that says how many rows.
I sort a copy and say the new order in a live region.
I cap or summarise big data and say what was left out.