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.

Figure containing chart and table mirror A figure wraps a visual chart and a scoped HTML table of the same data, with a toggle button revealing the table. figure visual chart table mirror (sr-only) Q1 120 Q2 180 figcaption names both · toggle reveals table
One figure contains the visual chart and a scoped, visually hidden table of identical data, joined by the caption and a reveal toggle.

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

  1. 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.
  2. 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.
  3. 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 as Row[].
  4. 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.
  5. Add an accessible toggle. Render a <button> with aria-expanded and aria-controls={tableId} pointing at the table’s id. Toggle a visibility state on click.
  6. Keep the table reachable when collapsed. Apply an sr-only class (clip, not display: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.
  7. Associate via headers for complex tables. For multi-dimensional data where scope is insufficient, give each <th> an id and reference them from each <td> with headers="...".

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 a scope attribute: document.querySelectorAll('#sales-table th[scope]').length === expectedHeaders.
  • Toggle state is correct. Click the button and confirm aria-expanded flips 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-hidden canvas 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:none removes data entirely. If your collapsed state uses display:none or hidden, screen-reader users lose access when the table is “hidden.” Use the sr-only clip 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.