Topic 15.7
Build: A Dashboard Meera Can Use
In one line
Give every chart on the dashboard a name, a description written from its data and a table twin, add a day reader and a one-stop bar list for the keyboard, and say changes aloud. It answers Meera: she can now get each chart's answer without seeing it.
Before you start
You will learn
- Add two shared parts,
describe.js(names, descriptions, spoken messages) andtable-twin.js(the table beside a chart) - Give the React rides line a description, a table twin and a day reader, and the stations bars a roving tabindex and a spoken update
- Give the plain D3 charts mounted by
D3Box(stacked, heatmap, treemap, map and its trips layer) the same words and tables by changing their modules - Run the six-check audit and an axe scan on the result
You write code in
In your Vite project lumen-dashboard/ (run npm run dev), and in the editor on this page
Files you touch
- src/parts/describe.js
- src/parts/table-twin.js
- src/charts/RidesLine.jsx
- src/charts/StationsBar.jsx
- src/charts/stations-stacked.js
- src/charts/june-heatmap.js
- src/charts/fleet-treemap.js
- src/charts/station-map.js
- src/charts/trips-canvas.js
- src/styles/charts.css
Think of it like this
The same operations room, but now every wall sheet has a typed list pinned beside it, and someone reads out the headline of each sheet when it changes. People who look at the sheets lose nothing. People who cannot look can run the room too.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- describe.js
- Our file of pure functions that turn rows into a name and a description, plus
labelChartandannounce. - table-twin.js
- Our helper that builds the table twin: caption, sortable headings, a details wrapper and a row cap.
- day reader
- The slider over the rides line that the arrow keys move from day to day.
- roving row list
- A transparent list over the stations bars with one Tab stop and arrow-key movement.
- accessibilityLayer
- A Recharts option that adds its own focus and keyboard behaviour to the chart. We turn it off and use our own.
- mounted chart
- A plain D3 chart drawn inside a React box by D3Box.
Step by step
01Start from your dashboard
You have the Phase 14 React project running with npm run dev. Every chart still works. This build only adds words, tables and keyboard paths.
02Create the two shared parts
Add src/parts/describe.js, then src/parts/table-twin.js (it imports announce from describe.js). Nothing else imports them yet.
03Replace the React charts
Replace src/charts/RidesLine.jsx and src/charts/StationsBar.jsx. App.jsx does not change: both components keep their props.
04Replace the mounted charts and the CSS
Replace stations-stacked.js, june-heatmap.js, fleet-treemap.js, station-map.js, trips-canvas.js and charts.css. The map hands container and station to the trips layer so it can add its own table.
05Check it with the keyboard and the audit
Press Tab through the page: the rides line is one stop, the bars are one stop. Open each table. Change the month and listen for the announcement. Run the six checks from Topic 15.6 on each chart, then ask Meera to find the busiest station in July.
Try it yourself
- 1
Start the stations chart on January
In
StationsBoxchangeuseState("Jul")touseState("Jan"). The Console printsstations bars: group "Rides per station in Jan, bar chart"androws: 21: the four stations opened in May are not in January, and the name, the row list and the table all follow the data.
Code & diagrams
The project's imports of local files can't run in the preview, so the code of D3Box, describe.js, table-twin.js, RidesLine, StationsBar, App and the plain stacked, heatmap and treemap charts is joined into one example, and the loaders are short stand-ins that read the same data. The station map and its rides layer (station-map.js, trips-canvas.js and their helpers) are not joined in: its box shows a one-line stand-in, as in Phase 14. In your project each file stays on its own, and the map has its description and tables too. The Console prints what the page offers a screen reader, and presses a key on the day reader.
Preview
Console
Nothing logged yet.Expected console output
key numbers: 365 | 933,651 | 18 Jul 2025 (4,380) | 14 Aug 2025 (439) | 0
rides line: img "Line chart of rides per day"
description: 365 days, from 1 Jan 2025 to 31 Dec 2025. It starts at 1,193 rides, peaks at 4,380 on 18 Jul 2025 and ends at 910. The quietest day is 14 Aug 2025, with 439 rides (app down).
table twin rows: 12 | Tab stops on the svg: 0
stations bars: group "Rides per station in Jul, bar chart"
rows: 25 | Tab stops among them: 1 | table twin rows: 25 | svgs with a tabindex: 0
stations-stacked: Stacked bar chart of rides per month by district | twin rows: 12
june-heatmap: Heatmap of rides per hour in June, days down and hours across | twin rows: 30
fleet-treemap: Treemap of the fleet by district and station | twin rows: 25
day reader after one ArrowLeft: Tue 30 Dec 2025: 1,446 rides
after Summer: 1 Jun to 31 Aug: 334,321 rides
spoken: Rides per station in the selected dates, bar chart. 25 stations, 334,321 rides in all. Quay is busiest with 21,010 and Windmill is quietest with 5,305. Next after Quay: Lantern 20,406, Library 19,653.Your project files
Your lumen-dashboard project after this topic. Click a file to see it in full.
The whole file after this topic. If yours differs, you can copy this one and carry on.
// Writes the words for a chart from its data, so the words can never disagree with the picture. Also speaks short messages to screen readers.
import { formatDay, formatNumber } from "../utils/format.js";
let counter = 0; // makes each description paragraph's id different
/** Say something to a screen reader without moving focus. One polite live region for the whole page, made on first use. */
export function announce(text) {
let region = document.getElementById("announcer");
if (!region) {
region = document.createElement("div");
region.id = "announcer";
region.setAttribute("role", "status"); // role=status is a polite live region
region.style.cssText = "position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap";
document.body.append(region);
}
region.textContent = ""; // clear first: the same sentence twice in a row is only spoken if the text really changes
setTimeout(() => { region.textContent = text; }, 100);
}
/**
* Give a chart (an <svg> or a <canvas>) its short name and its longer description.
* The name goes in aria-label. The description becomes a visible paragraph under the chart, joined with aria-describedby.
* Call it again after every update: the paragraph is changed in place, so the words follow the data.
*/
export function labelChart(node, { name, text }) {
let note = node.nextElementSibling;
if (!note || !note.classList.contains("desc")) {
note = document.createElement("p");
note.className = "desc";
note.id = "desc-" + ++counter;
node.after(note);
}
note.textContent = text; // text only, never markup
node.setAttribute("aria-label", name);
node.setAttribute("aria-describedby", note.id);
if (node.tagName === "CANVAS") node.textContent = name + ". " + text; // what a browser without canvas support shows
}
const plural = (n, one, many) => formatNumber(n) + " " + (n === 1 ? one : many);
/** rows: [{ date, rides }]. notes: { "2025-08-14": "App down" }, a word for a day people will ask about. */
export function describeRides(rows, notes = {}) {
const name = "Line chart of rides per day";
const days = rows.filter((d) => Number.isFinite(d.rides)).sort((a, b) => a.date - b.date);
if (days.length === 0) return { name: name + ": no data", text: "There are no rides to show." };
const first = days[0], last = days[days.length - 1];
const gaps = rows.length - days.length; // rows with no usable number: the line shows a gap there
const gapText = gaps > 0 ? " (" + plural(gaps, "day has", "days have") + " no number and show" + (gaps === 1 ? "s" : "") + " as a gap)" : "";
if (days.length === 1) return { name, text: "One day with a number: " + formatDay(first.date) + ", " + plural(first.rides, "ride", "rides") + gapText + "." };
const high = days.reduce((a, b) => (b.rides > a.rides ? b : a));
const low = days.reduce((a, b) => (b.rides < a.rides ? b : a));
const word = notes[low.date.toISOString().slice(0, 10)];
let text = plural(rows.length, "day", "days") + ", from " + formatDay(first.date) + " to " + formatDay(last.date) + gapText + ". ";
if (high.rides === low.rides) text += "Every day had " + plural(high.rides, "ride", "rides") + ".";
else {
text += "It starts at " + formatNumber(first.rides) + " rides, peaks at " + formatNumber(high.rides) + " on " + formatDay(high.date) +
" and ends at " + formatNumber(last.rides) + ". The quietest day is " + formatDay(low.date) + ", with " + formatNumber(low.rides) +
" rides" + (word ? " (" + word.toLowerCase() + ")" : "") + ".";
}
return { name, text };
}
/** rows: [{ name, rides }], any order. label: the chart's subject, for example "Rides per station". */
export function describeBars(rows, subject) {
const sorted = rows.filter((d) => Number.isFinite(d.rides)).sort((a, b) => b.rides - a.rides);
const name = subject + ", bar chart";
if (sorted.length === 0) return { name: name + ": no data", text: "No stations had any rides in this period." };
const top = sorted[0], bottom = sorted[sorted.length - 1];
const total = sorted.reduce((sum, d) => sum + d.rides, 0);
let text = plural(sorted.length, "station", "stations") + ", " + plural(total, "ride", "rides") + " in all. ";
if (top.rides === bottom.rides) text += "Every station had " + formatNumber(top.rides) + ".";
else {
text += top.name + " is busiest with " + formatNumber(top.rides) + " and " + bottom.name + " is quietest with " + formatNumber(bottom.rides) + ".";
if (sorted.length >= 4) text += " Next after " + top.name + ": " + sorted.slice(1, 3).map((d) => d.name + " " + formatNumber(d.rides)).join(", ") + ".";
}
return { name, text };
}
/** months: [{ month, total }]; districts: [{ name, total }]. Both are the sums the chart draws. */
export function describeStacked(months, districts) {
const name = "Stacked bar chart of rides per month by district";
const total = months.reduce((sum, d) => sum + d.total, 0);
if (total === 0) return { name: name + ": no data", text: "There are no rides to show." };
const busy = months.reduce((a, b) => (b.total > a.total ? b : a));
const quiet = months.reduce((a, b) => (b.total < a.total ? b : a));
const lead = districts.reduce((a, b) => (b.total > a.total ? b : a));
return {
name,
text: plural(total, "ride", "rides") + " in " + plural(months.length, "month", "months") + ". The busiest month is " + busy.month + " with " + formatNumber(busy.total) +
" and the quietest is " + quiet.month + " with " + formatNumber(quiet.total) + ". " + lead.name + " carried the most, " +
Math.round((lead.total / total) * 100) + " percent of all rides.",
};
}
/** cells: [{ day, hour, rides }]. */
export function describeHeatmap(cells) {
const name = "Heatmap of rides per hour in June, days down and hours across";
const total = cells.reduce((sum, d) => sum + d.rides, 0);
if (cells.length === 0 || total === 0) return { name: name + ": no data", text: "There are no rides to show." };
const byHour = new Map();
for (const d of cells) byHour.set(d.hour, (byHour.get(d.hour) ?? 0) + d.rides);
const [hour, hourTotal] = [...byHour].reduce((a, b) => (b[1] > a[1] ? b : a));
const hot = cells.reduce((a, b) => (b.rides > a.rides ? b : a));
const at = (h) => String(h).padStart(2, "0") + ":00";
return {
name,
text: "Across the month the busiest hour of the day is " + at(hour) + ", with " + formatNumber(hourTotal) + " rides. The single busiest hour was " +
at(hot.hour) + " on " + hot.day + " June, with " + formatNumber(hot.rides) + " rides.",
};
}
/** For the station map: stations is [{ name, district, docks }]. */
export function describeStations(stations) {
const name = "Map of the bike stations";
if (stations.length === 0) return { name: name + ": no data", text: "There are no stations to show." };
const districts = new Set(stations.map((s) => s.district));
const big = stations.reduce((a, b) => (b.docks > a.docks ? b : a));
return {
name,
text: plural(stations.length, "station", "stations") + " in " + plural(districts.size, "district", "districts") + ". Circle size shows docks: " + big.name + " is the biggest with " + formatNumber(big.docks) +
". The table lists every station. Dots from the rides layer sit near their station: they show how many rides start there, not where on the street.",
};
}
/** For the fleet treemap: bikes is the total, districts is [{ name, bikes }], count the number of stations. */
export function describeFleet(bikes, districts, count) {
const name = "Treemap of the fleet by district and station";
if (!(bikes > 0)) return { name: name + ": no data", text: "There are no bikes in the fleet data." };
const lead = districts.reduce((a, b) => (b.bikes > a.bikes ? b : a));
return { name, text: plural(bikes, "bike", "bikes") + " in " + plural(districts.length, "district", "districts") + " and " + plural(count, "station", "stations") + ". Area shows bikes. " + lead.name + " has the most, " + formatNumber(lead.bikes) + "." };
}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
Let Recharts add its own keyboard layer
In StationsBar.jsx delete accessibilityLayer={false} from <BarChart ...>.
Break #2
Read the ref inside the cleanup
In RidesLine.jsx, in the table twin effect, delete the line starting const el = twinBox.current;, call tableTwin(twinBox.current, { and write twinBox.current.replaceChildren(); in the cleanup.
Myth vs fact
Myth
A chart library's accessibility layer is enough.
Fact
Recharts' own layer added a Tab stop to an SVG we had hidden from screen readers. Read what a library adds, and test it with the keyboard.
Myth
Adding tables doubles the page's weight.
Fact
A twin is a closed details of at most a few hundred cells: 12 rows for the year, 25 for the stations. Big data is summarised first (Topic 15.3).
Myth
Accessibility belongs in a separate pass at the end.
Fact
Here it is a handful of pure functions and two small components that every chart uses. Adding the next chart costs one call.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The announcement is made from an effect that compares the new words with the last ones, so a first draw is silent and StrictMode's double run says nothing twice. A live region that speaks on every render is the common way accessibility work makes a page unusable.
- ▸
The map keeps its Phase 13 keyboard model (
role="application", arrow keys, Enter), and now has a description and a table of the stations.role="application"turns off a screen reader's own browsing keys over the map, so it is worth a session with a real screen reader before keeping it. - ▸
Both mounted-chart helpers use
nextElementSiblingto find their description paragraph. If you wrap a chart in another element, move the paragraph with it, orlabelChartmakes a second one.
Remember this
- 1
Meera's need is met. She opens the dashboard and every chart has a name, a short description with the finding, and a table of its numbers. The rides line has a day reader she can move with the arrow keys, the stations bars are one Tab stop with the arrow keys moving between stations, and a changed month or range is spoken. She asked for the answer of each chart: she can get it.
- 2
Starting point: the Phase 14 dashboard, a React app. You have
lumen-dashboard/with everything from Phases 4 to 14:RidesLine.jsx(D3 maths, React marks, brush, crosshair),StationsBar.jsx(Recharts), and the plain D3 charts mounted throughD3Box(stacked months, June heatmap, fleet treemap, the station map with its rides layer).src/main.jsis retired. Ten files change:describe.jsandtable-twin.jsare new, and eight existing files are edited.App.jsx,D3Box.jsxandindex.htmldo not change. - 3
How the parts plug in. Names and descriptions are pure functions of the rows (
describeRides,describeBars,describeStacked,describeHeatmap,describeFleet,describeStations), so a React component calls them while it renders and a plain D3 module calls them while it draws (labelChartwrites the result onto the node). The table twin is plain DOM, so React mounts it into an empty<div>it never looks inside, the same one-owner rule asD3Box(Topic 14.1). The cleanup keeps the element it started with, because React clears a ref before its cleanup runs. - 4
Keyboard. The rides line has one
role="slider"over the chart: arrows, Page Up and Down (a week), Home and End, each speaking the day througharia-valuetext. The stations bars hide Recharts' own SVG from assistive technology (accessibilityLayer={false}stops it from adding a Tab stop of its own) and lay a transparent list over the bars: one row hastabindex="0"and the arrow keys move it. Tab and every other key are left to the browser, and the existing buttons for the brush keep dragging optional (WCAG 2.5.7). - 5
What the dashboard can do now that it could not before: every chart has a role, a name and a description built from its data; every chart has a table twin (the rides line sums months, the heatmap sums days, the treemap and the map list their stations); the rides line and the stations bars work from the keyboard; a new month or range is spoken. Measured on the project: axe-core 4.13.0 reports 0 violations in light and dark, and 1 item it cannot decide (
color-contrast, 141 nodes); the page is 320 pixels wide at a 320 pixel window; keyboard users have 16 Tab stops where the Phase 14 page had 9, because each of the seven tables is a closeddetailswith one stop. - 6
Still missing: the live feed and every loading, partial and error state at scale (Phase 16); the checks written as tests that run on every build (Phase 16); a visible pause for anything that updates by itself (Phase 16); and a real session with Meera on her own screen reader, which no scan replaces. The heatmap and the map's rides layer have no cell-by-cell keyboard path: their tables carry the numbers. The old plain
rides-line.jsandstations-bar.jsof Phases 6 to 10 are no longer used by the React app and were not changed.
Explain it without notes
Why are the descriptions pure functions of the rows and not text in the components?
How does a React component use a vanilla helper such as tableTwin without the two fighting over the DOM?
Why does the stations chart need both a hidden Recharts SVG and a row list?
What does the dashboard still not do for Meera?
Practice
Add a description to a new chart in one line, using the helpers. Which two calls are needed for a plain D3 chart, and which for a React one?
Why does StationsBar compare the new words with the last ones before it announces?
Trade-offs
- ↔
Hiding Recharts' SVG and laying a row list over it costs about thirty lines and loses Recharts' own keyboard behaviour. It gives one Tab stop and the exact words we choose.
- ↔
A table twin for every chart adds seven Tab stops. Each is a closed
details, so a keyboard user can skip them by section, and each answers a question the chart cannot answer to someone who cannot see it.
Done when you can
My project has describe.js, table-twin.js and the eight changed files.
Every chart has a name, a description and a table I can open.
The rides line is one Tab stop with a day reader, and the stations bars are one Tab stop.
I changed the month and saw the announcement, and ran the six-check audit.
I can say what is still missing and which phase adds it.