How to make canvas charts accessible
The real-DOM overlay pattern: per-point screen-reader access on a
<canvas> chart, without giving up 60fps.
To a screen reader, a canvas chart is a single opaque rectangle. No values, no keyboard
path, nothing find-in-page can reach. The usual advice — "add an aria-label" —
turns a thousand data points into one sentence, which fails the moment a user asks the
obvious question: what was the value on Tuesday?
The common conclusion is that you must switch to SVG and render a DOM node per point. That works until it doesn't: at tens of thousands of points, per-point DOM is why accessible charts have a reputation for being slow. But the trade-off isn't fundamental. The fix is to keep canvas for pixels and build a small, real DOM for meaning.
What assistive technology actually needs
- A name and a summary. On focus, the chart should identify itself and give the shape of the data (range, current value, trend) — not just "chart".
- Per-point access. A keyboard cursor: arrow keys move between samples, each move is announced with its x and y values.
- Announcements that reach the reader. Value changes go through a
polite
aria-liveregion, debounced so holding a key doesn't flood it. - A text alternative with structure. A (visually hidden) data
<table>with real headers, so users can leave interaction mode and read rows. - Real text where there is text. Axis ticks drawn as DOM text are readable, translatable, and Ctrl+F-findable; ticks painted into the canvas are pixels.
The overlay pattern, piece by piece
Everything sits inside the chart container, positioned over the canvas:
<div class="chart-root">
<canvas aria-hidden="true"></canvas> <!-- pixels only -->
<div role="application" tabindex="0" <!-- the keyboard surface -->
aria-label="Revenue over time. Arrow keys move between samples…"
aria-describedby="summary" aria-details="data-table"></div>
<div class="ticks">…real text nodes…</div>
<div aria-live="polite" aria-atomic="true" class="visually-hidden"></div>
<p id="summary" class="visually-hidden">Revenue ranges 180–290, now 287, up 12%.</p>
<table id="data-table" class="visually-hidden">…downsampled rows…</table>
</div>
role="application"on the focus surface tells screen readers to pass arrow keys through to your handler instead of consuming them for virtual-cursor navigation. Name the keys in the accessible name — users can't discover an interaction model that is never announced.- The keyboard handler moves a data cursor and writes one sentence into the live region per settled position. Debounce (~100 ms): key-repeat should announce the landing point, not every step.
- The table doesn't need every point. Downsample to a bounded number of rows over the visible range — structure matters more than completeness.
- Cost discipline: the DOM layer updates on interaction, never per frame. The render loop stays canvas-only, which is why this pattern doesn't move the frame budget.
Details that separate "has ARIA" from "works in a screen reader"
- Test with a real reader, continuously. ARIA attributes are claims; the spoken output is the truth. fcharts runs NVDA in CI on every push and asserts on the actual phrases — attribute-level tests missed things the reader caught.
- Respect user preferences in the canvas too:
prefers-reduced-motion(stop auto-scroll/animation),forced-colors(remap canvas colors for Windows High Contrast), visible:focus-visiblering around the surface. - Non-text contrast (WCAG 1.4.11) applies to the marks themselves — series lines and UI affordances need ≥3:1 against the background.
- Keyboard parity for pointer gestures: wheel-zoom needs
+/-; drag-pan needs an alternative (WCAG 2.5.7).
Steal the implementation
All of the above ships by default in fcharts
(MIT, zero dependencies, ~21 KB gzip): the overlay layer is a dozen small files under
src/a11y/, renderer-agnostic, and liftable into other canvas charts. The
live demo runs 100k points at 60fps with everything described here turned on.