Exposing Chart Data as an Accessible Data Table
A chart communicates through shape and color, none of which a screen reader can read, so the underlying numbers must also exist as a real, navigable HTML table. This guide builds a visually hidden <table> mirror of the chart data, associates it with the figure, scopes its headers correctly, and adds a toggle so any user can reveal the raw values.
This guide is part of the screen-reader-friendly charts section within the accessible interactive data visualization overview. It is the static, exhaustive companion to ARIA live regions for real-time data streams: the live region announces change, the table provides the complete record.
Diagnostic checklist
Confirm these before building the table:
How the table mirrors the figure
The chart and its table are siblings inside one <figure>. The figure has a <figcaption> that names both. The table is a real, scoped table that is visually hidden by default and revealed by a toggle button.
Broken vs fixed
// ❌ BROKEN: a chart with only a one-line summary; no real data
function SalesChart({ rows }: { rows: Row[] }) {
return (
<canvas
width={300}
height={200}
// a single aria-label loses every individual value
aria-label="Quarterly sales bar chart"
ref={useBarChart(rows)}
/>
);
// No table, no per-row access. Screen-reader users get a summary and nothing else.
}
// ✅ FIXED: figure + visually hidden, scoped table + reveal toggle
interface Row { quarter: string; revenue: number; }
function SalesChart({ rows }: { rows: Row[] }) {
const [showTable, setShowTable] = useState(false);
const tableId = "sales-table";
return (
<figure>
<canvas
width={300}
height={200}
aria-hidden="true" // A11Y: defer all semantics to the table
ref={useBarChart(rows)}
/>
<figcaption>
Quarterly sales.{" "}
<button type="button" aria-expanded={showTable} aria-controls={tableId}
onClick={() => setShowTable((v) => !v)}>
{showTable ? "Hide" : "Show"} data table
</button>
</figcaption>
{/* A11Y: real table; sr-only keeps it for screen readers when collapsed */}
<table id={tableId} className={showTable ? "" : "sr-only"}>
<caption>Quarterly sales revenue</caption>
<thead>
<tr>
<th scope="col">Quarter</th>
<th scope="col">Revenue (USD)</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.quarter}>
<th scope="row">{r.quarter}</th> {/* row header, not td */}
<td>{r.revenue.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</figure>
);
}
The broken version reduces the entire dataset to one label. The fixed version hides the canvas from assistive tech, exposes every value in a real <table> with scope="col" and scope="row" headers, wraps both in a <figure> with a captioning toggle, and uses sr-only so the data is always available to screen readers even when visually collapsed.
Step-by-step fix
- Wrap the chart in a
<figure>. Place the visual chart and the table as siblings inside one<figure>so they are clearly one unit. Add a<figcaption>naming the chart. - Hide the visual layer from assistive tech. Set
aria-hidden="true"on the visual Canvas/SVG node so the screen reader does not try to interpret pixels; all semantics come from the table. - Build a real, scoped table. Use
<table>,<thead>,<tbody>,<th scope="col">for column headers and<th scope="row">for the row label cell. Add a<caption>describing the table. Type the rows asRow[]. - Mirror the data exactly. Generate the table rows from the same data array that feeds the chart, so the two can never drift. Format numbers with
toLocaleString()for readability. - Add an accessible toggle. Render a
<button>witharia-expandedandaria-controls={tableId}pointing at the table’sid. Toggle a visibility state on click. - Keep the table reachable when collapsed. Apply an
sr-onlyclass (clip, notdisplay:none) so screen-reader users always have the data, while sighted users see it only after pressing the toggle. If you prefer to fully remove it from the layout when hidden, ensure the screen-reader path still has access. - Associate via headers for complex tables. For multi-dimensional data where
scopeis insufficient, give each<th>anidand reference them from each<td>withheaders="...".
Verification
- Navigate the table. With a screen reader in table mode, move cell by cell and confirm each value is announced with its column and row header (for example “Q2, Revenue, 180,000”).
- Headers are real. In DevTools, assert the header cells are
<th>with ascopeattribute:document.querySelectorAll('#sales-table th[scope]').length === expectedHeaders. - Toggle state is correct. Click the button and confirm
aria-expandedflips and the table becomes visible.console.assert(btn.getAttribute("aria-expanded") === String(open)). - Data parity. Diff the table cell values against the chart’s source array programmatically so the two always match.
- Canvas is ignored. Confirm the screen reader skips the
aria-hiddencanvas rather than announcing “canvas”.
Edge cases & gotchas
- Huge datasets. A table mirroring 50,000 points is technically accessible but practically unusable. Provide a summarized table (aggregates, top-N, or paginated ranges) plus a download link to the full data, and keep arrow-key chart traversal for spot exploration.
display:noneremoves data entirely. If your collapsed state usesdisplay:noneorhidden, screen-reader users lose access when the table is “hidden.” Use thesr-onlyclip technique so the data persists for assistive tech regardless of the visual toggle, or render it visibly on toggle only.- Sorted or transformed charts. If the chart sorts or filters interactively, regenerate the table from the same transformed array so the table reflects exactly what is plotted, not the raw source. This is the same single-source-of-truth discipline used across the screen-reader-friendly charts guides.
Frequently Asked Questions
Isn’t an aria-label summary on the chart enough?
No. A single aria-label like “sales rose in Q3” conveys a trend but discards every individual value, so screen-reader users cannot read specific data points or compare them. A summary is a useful addition but never a substitute for the underlying table.
Why use th with scope instead of styling div cells?
Real <th> cells with scope="col"/scope="row" let screen readers announce each data cell together with its headers and enable table navigation commands. A grid of <div>s has none of that semantics, so users cannot orient themselves within the data.
Should the table be visible or hidden by default?
Either is acceptable as long as the data is always available to assistive technology. Hiding it with an sr-only clip keeps the visual design clean while remaining fully accessible; a toggle lets sighted users opt in. Avoid display:none for the collapsed state because it removes the data from everyone.
How do I keep the table in sync with the chart?
Generate both from the same data array in the same render. Never maintain the table by hand. If the chart applies sorting or filtering, build the table from the transformed array so the two cannot disagree.
Related
- Screen-reader-friendly charts — the parent section for screen-reader patterns.
- ARIA live regions for real-time data streams — announce change while the table holds the full record.
- SVG vs Canvas architecture — why neither renderer exposes data textually on its own.