Canvas & SVG Chart Keyboard Interaction
Permalink to "Canvas & SVG Chart Keyboard Interaction"A chart that responds only to hover and click is invisible to keyboard and screen reader users no matter how carefully its colours were chosen. Making a chart truly interactive for everyone means the same thing an interactive chart already does for mouse users: let a person move to any data point, read its exact value, and understand where that point sits among its series and the whole plot. This page details the focus model, the ARIA roles, the arrow-key traversal contract, and the announcement strategy that turn a decorative svg or canvas element into an operable widget under WCAG 2.2. It sits alongside the non-interactive baseline covered in data visualization and chart alternatives: every interactive chart still needs that text-and-table equivalent underneath it.
Keyboard operability is not a substitute for the alternatives layer — it is a second, richer path through the same data. A sighted keyboard user wants the visual focus ring to walk the line; a screen reader user wants each stop announced; both want a table they can fall back to. Get all three and the chart passes; drop one and a class of users is stranded.
WCAG Criteria in Scope
Permalink to "WCAG Criteria in Scope"| Criterion | Level | Relevance to this pattern |
|---|---|---|
| 1.1.1 Non-text Content | A | The chart and each data point need a text alternative that names the value |
| 1.3.1 Info and Relationships | A | Series membership and point order must be programmatically exposed, not just drawn |
| 1.4.1 Use of Color | A | Series must be distinguishable without relying on hue alone |
| 1.4.11 Non-text Contrast | AA | The focus indicator on a data point must meet 3:1 against its surroundings |
| 2.1.1 Keyboard | A | Every data point and control must be reachable and operable from the keyboard |
| 2.1.2 No Keyboard Trap | A | Focus must be able to enter and leave the chart without a mouse |
| 2.4.3 Focus Order | A | Traversal between points and series must follow a logical, predictable order |
| 2.4.7 Focus Visible | AA | The currently focused data point must show a visible indicator |
| 4.1.2 Name, Role, Value | A | Each focusable point must expose its name, role, and current value to AT |
Prerequisites
Permalink to "Prerequisites"This page builds on two references you should read first:
- Data visualization and chart alternatives — the baseline text summary and structured
<table>fallback that every chart, interactive or not, must carry. - Generating accessible text alternatives for D3 charts — how to derive the per-point strings (series, category, value, trend) that the announcements on this page reuse verbatim.
You should also be comfortable with choosing between polite and assertive aria-live regions, because the value announcement on focus depends on getting the politeness level right, and with the roving tabindex idea from implementing roving tabindex for custom data grids — the chart reuses exactly that single-tab-stop model.
The focus-traversal model
Permalink to "The focus-traversal model"The core design decision is how a keyboard user moves through a two-dimensional field of points that belong to one or more series. The model below is the one most consistent with how sighted users read a line chart: Tab enters the chart at a single stop, Left/Right walk along the current series in category order, and Up/Down jump between series at the same category. Every stop announces the series name, the category, and the value.
This is a roving tabindex grid rotated onto chart coordinates: the whole plot is one tab stop, one point holds tabindex="0", every other point holds tabindex="-1", and arrow keys move the 0. Home and End jump to the first and last category of the current series, and Escape returns focus to a stable element outside the plot so the user is never trapped (WCAG 2.1.2).
SVG vs. canvas: native focus vs. hit-testing
Permalink to "SVG vs. canvas: native focus vs. hit-testing"The two rendering technologies demand different accessibility strategies.
SVG produces real DOM elements. A <circle> or <path> can take tabindex, an ARIA role, and an accessible name, and the browser exposes it to assistive technology directly. Focus is native, the focus ring is CSS-styleable, and hit-testing for the visual layer is handled by the browser. This is the strongly preferred substrate for interactive charts and the one the deep-dive on SVG line charts below develops in full.
Canvas paints pixels into a bitmap. There is no per-point DOM node, so there is nothing to focus and nothing for a screen reader to read. You must build a parallel focusable structure — a set of hidden buttons or a table inside the canvas element’s fallback content slot (the child nodes between <canvas> and </canvas>, which the browser exposes to AT). Keyboard events are routed to your own hit-testing code, and you redraw a visible focus indicator onto the bitmap yourself each time focus moves. The fallback structure carries the semantics; the canvas carries only the picture.
| Concern | SVG | Canvas |
|---|---|---|
| Focusable unit | The <circle>/<path> node itself |
A hidden proxy element (button/<td>) per point |
| Accessible name | aria-label or <title> child on the node |
On the proxy element |
| Focus indicator | CSS :focus-visible outline on the node |
Manually redrawn onto the bitmap on each move |
| Hit-testing | Browser-native for pointer; you own keyboard | You own both pointer and keyboard |
| Recommended for | Any interactive chart under ~2,000 points | High-density plots where SVG node count blows the DOM budget |
For dense canvas plots, watch the DOM size budget: a hidden proxy per point can reintroduce exactly the node explosion canvas was chosen to avoid. Above a few thousand points, expose focusable proxies only for the currently navigated series and rebuild them as the user switches series.
ARIA & role reference
Permalink to "ARIA & role reference"role="img" on the root — and when to drop it
Permalink to "role="img" on the root — and when to drop it" A non-interactive chart uses role="img" on the svg element with an aria-label (or aria-labelledby) so AT announces it as a single image with a name. But role="img" collapses the element’s subtree in the accessibility tree — screen readers do not expose the children. The moment you make individual points focusable, role="img" on the root would hide those very points. So for an interactive chart, remove role="img" from the root and instead expose the plot as a labelled grouping whose children are individually reachable.
role="graphics-document" and role="graphics-symbol"
Permalink to "role="graphics-document" and role="graphics-symbol"" The WAI-ARIA Graphics module defines role="graphics-document" for the chart container and role="graphics-symbol" for an individual, meaningful graphic such as a data point. Support is uneven, so treat these as progressive enhancement layered over a robust name/role/value baseline: the load-bearing parts are tabindex, the accessible name, and the arrow-key handler, not the graphics roles.
Accessible name per point (WCAG 4.1.2)
Permalink to "Accessible name per point (WCAG 4.1.2)"Every focusable point needs a name that states series, category, and value — the same string you already generate for the text alternatives. Use aria-label on the SVG node, or a <title> child (first child of the shape). aria-label wins for dynamically updated charts because it is a single attribute write.
aria-activedescendant vs. roving tabindex
Permalink to "aria-activedescendant vs. roving tabindex" Two patterns can track “which point is current”. Roving tabindex (move tabindex="0" and call .focus()) works reliably across SVG in every major AT and is the recommended default. aria-activedescendant keeps DOM focus on the container and points an attribute at the active child; it can be lighter for very large plots but has thinner SVG support. The patterns here use roving tabindex; the composite-widget alternative is covered under using aria-activedescendant for grid cell focus.
Step-by-step implementation
Permalink to "Step-by-step implementation"The steps below build an SVG line chart with two series. The same event logic drives a canvas chart if you swap “focus the SVG node” for “focus the hidden proxy and redraw the ring”.
Step 1 — Expose the container and remove role="img" (WCAG 1.3.1, 4.1.2)
Permalink to "Step 1 — Expose the container and remove role="img" (WCAG 1.3.1, 4.1.2)" <!-- SC 1.3.1: figure + figcaption give the chart a programmatic name -->
<!-- No role="img" on the svg root: it would hide the focusable points below -->
<figure>
<figcaption id="chart-cap">Weekly active users by plan — Jan to May</figcaption>
<svg aria-labelledby="chart-cap" width="600" height="300" class="line-chart">
<!-- Series groups and points injected in Step 2 -->
<g role="group" aria-label="Pro plan" data-series="pro"></g>
<g role="group" aria-label="Free plan" data-series="free"></g>
</svg>
<!-- Data-table equivalent injected in Step 5 -->
</figure>
Step 2 — Make each point focusable with a full accessible name (WCAG 2.1.1, 4.1.2)
Permalink to "Step 2 — Make each point focusable with a full accessible name (WCAG 2.1.1, 4.1.2)"// SC 2.1.1: tabindex makes each point reachable from the keyboard
// SC 4.1.2: aria-label exposes series, category, and value to AT
function renderPoints(seriesGroup, series) {
series.points.forEach((pt, i) => {
const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
c.setAttribute('cx', pt.x);
c.setAttribute('cy', pt.y);
c.setAttribute('r', 6);
// Roving tabindex: only the very first point starts reachable
c.setAttribute('tabindex', series.index === 0 && i === 0 ? '0' : '-1');
c.setAttribute('role', 'graphics-symbol'); // progressive enhancement
// The name reuses the same string used for the table + text summary
c.setAttribute('aria-label',
`${series.name}, ${pt.category}, ${pt.value} users`);
c.dataset.series = series.index;
c.dataset.point = i;
seriesGroup.appendChild(c);
});
}
Step 3 — Wire Left/Right along a series, Up/Down between series (WCAG 2.4.3)
Permalink to "Step 3 — Wire Left/Right along a series, Up/Down between series (WCAG 2.4.3)"// SC 2.4.3: arrow keys move focus in a predictable, logical order
// grid[s][p] = the <circle> for series s, point p
function onKeydown(e) {
const el = e.target;
if (!el.dataset.series) return;
let s = Number(el.dataset.series);
let p = Number(el.dataset.point);
const lastP = grid[s].length - 1;
switch (e.key) {
case 'ArrowRight': p = Math.min(p + 1, lastP); break;
case 'ArrowLeft': p = Math.max(p - 1, 0); break;
case 'ArrowDown': s = Math.min(s + 1, grid.length - 1); break;
case 'ArrowUp': s = Math.max(s - 1, 0); break;
case 'Home': p = 0; break;
case 'End': p = lastP; break;
case 'Escape': document.getElementById('chart-exit').focus(); return;
default: return; // let other keys through — no keyboard trap (SC 2.1.2)
}
e.preventDefault();
moveFocus(grid[s][Math.min(p, grid[s].length - 1)]);
}
Step 4 — Move the roving tabindex and announce the value (WCAG 2.4.7, 4.1.3)
Permalink to "Step 4 — Move the roving tabindex and announce the value (WCAG 2.4.7, 4.1.3)"// SC 2.4.7: the focused node shows a visible ring (styled in Step 6)
// SC 4.1.3: a polite live region echoes the value without stealing focus
const liveRegion = document.getElementById('chart-live'); // role=status in HTML
function moveFocus(next) {
// Roving tabindex: exactly one node is focusable at a time
chartRoot.querySelectorAll('[tabindex="0"]')
.forEach(n => n.setAttribute('tabindex', '-1'));
next.setAttribute('tabindex', '0');
next.focus();
// Some AT read aria-label on SVG focus reliably; the live region is a
// belt-and-braces echo for those that do not. Clear then set to re-fire.
liveRegion.textContent = '';
requestAnimationFrame(() => {
liveRegion.textContent = next.getAttribute('aria-label');
});
}
Step 5 — Provide the data-table equivalent (WCAG 1.1.1, 1.3.1)
Permalink to "Step 5 — Provide the data-table equivalent (WCAG 1.1.1, 1.3.1)"<!-- SC 1.1.1 + 1.3.1: the full dataset in a real table AT can navigate -->
<!-- Visually hidden by default; can be revealed by a "show data" toggle -->
<table class="sr-only" aria-labelledby="chart-cap">
<thead>
<tr>
<th scope="col">Week</th>
<th scope="col">Pro plan</th>
<th scope="col">Free plan</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">Week 1</th><td>1,240</td><td>3,010</td></tr>
<tr><th scope="row">Week 2</th><td>1,388</td><td>3,102</td></tr>
<!-- one row per category -->
</tbody>
</table>
Step 6 — Make the focus ring visible on SVG nodes (WCAG 1.4.11, 2.4.7)
Permalink to "Step 6 — Make the focus ring visible on SVG nodes (WCAG 1.4.11, 2.4.7)"/* SC 2.4.7 + 1.4.11: a 3:1 focus indicator that works on any plot background */
.line-chart circle:focus-visible {
outline: none; /* replace the UA outline, which clips inside SVG */
stroke: currentColor;
stroke-width: 3;
/* A halo that reads against both light and dark series colours */
paint-order: stroke;
filter: drop-shadow(0 0 0 2px Canvas);
}
/* Enlarge the hit/target area so the ring is unmistakable */
.line-chart circle:focus-visible { r: 9; }
Keyboard interaction contract
Permalink to "Keyboard interaction contract"| Key | Context | Action | Expected AT announcement | Failure indicator |
|---|---|---|---|---|
Tab |
Focus outside chart | Enter plot at the current/first point | “Pro plan, Week 1, 1,240 users” | Tab skips the chart entirely; points unreachable |
Right Arrow |
On a point | Next category in the same series | “Pro plan, Week 2, 1,388 users” | Focus jumps to another series or off the chart |
Left Arrow |
On a point | Previous category in the same series | “Pro plan, Week 1, 1,240 users” | Wraps unexpectedly or does nothing |
Down Arrow |
On a point | Same category, next series | “Free plan, Week 1, 3,010 users” | Silent; visual dot moves but AT unchanged |
Up Arrow |
On a point | Same category, previous series | “Pro plan, Week 1, 1,240 users” | No series label in the announcement |
Home / End |
On a point | First / last category of the series | “…, Week 1…” / “…, Week 20…” | Scrolls the page instead of moving focus |
Escape |
On a point | Exit chart to a stable element | Focus lands on a labelled control after the chart | Focus trapped inside the plot (fails 2.1.2) |
Tab |
On a point | Leave the chart forward | Next page control announced | Tab cycles through every point (roving tabindex broken) |
Screen reader compatibility matrix
Permalink to "Screen reader compatibility matrix"| AT + Browser | SVG node focus | aria-label on focus |
Live-region echo | Known deviation |
|---|---|---|---|---|
| NVDA 2024 + Firefox | Focuses <circle> reliably |
Announces the label on focus | Fires at polite timing | May prepend “graphic”; keep labels concise |
| NVDA 2024 + Chrome | Focus works | Label read on focus | Occasional 1–2 s delay | Confirm graphics-symbol does not suppress the name |
| JAWS 2024 + Chrome | Focuses the node | Reads aria-label |
role="status" string read |
May not speak graphics-symbol role; name still read |
| VoiceOver + Safari (macOS) | Focus enters with VO+arrows or Tab |
Reads label | Live region can be suppressed mid-utterance | Prefers <title> child in some builds; provide both label and title if flaky |
| VoiceOver + Chrome (iOS) | Focus via swipe | Reads label | May fire twice | Use the clear-then-set rAF pattern to dedupe |
| TalkBack + Chrome (Android) | Swipe reaches points | Reads label | Fires after current utterance | Touch users need a “data table” affordance; arrow keys need an external keyboard |
Edge cases & failure modes
Permalink to "Edge cases & failure modes"1. role="img" left on the root hides every point
Permalink to "1. role="img" left on the root hides every point" The most common failure: a chart library sets role="img" on the svg element for the non-interactive case, then interactivity is bolted on without removing it. AT announces “Weekly active users, image” and never exposes the focusable children. Remove role="img" (or swap it for graphics-document) the moment points become focusable.
2. Focus ring clipped by SVG overflow
Permalink to "2. Focus ring clipped by SVG overflow"A browser’s default focus outline is drawn in the element’s own coordinate space and is frequently clipped by the SVG viewport or by a point near the plot edge. Replace the UA outline with an in-SVG indicator (a thickened stroke plus enlarged r, as in Step 6) so it is never clipped and always meets 3:1 non-text contrast.
3. Announcement desync between visual dot and AT
Permalink to "3. Announcement desync between visual dot and AT"If you move the visible focus indicator by redrawing but forget to call .focus() on the node (common in canvas ports), the sighted keyboard user sees the dot move while the screen reader stays put. Keep the two in lockstep: one function moves the roving tabindex, calls .focus(), and updates the live region, and nothing else moves the indicator.
4. Sparse series with missing categories
Permalink to "4. Sparse series with missing categories"When Series B has no value at Week 3, Up/Down at Week 3 has no target. Decide the rule once: either skip to the nearest populated category and announce “no data at Week 3, moved to Week 4”, or hold position and announce “Free plan has no value at Week 3”. Silence is the failure — the user cannot tell whether the key did nothing or the data is absent.
5. Keyboard trap on Escape with no exit target
Permalink to "5. Keyboard trap on Escape with no exit target"If Escape calls .focus() on an element that has been removed or is display:none, focus falls to document.body and the user is dumped at the top of the page. Always exit to a stable, visible, labelled element that sits immediately after the chart in DOM order (WCAG 2.1.2, 2.4.3).
Adding keyboard navigation to SVG line charts
Permalink to "Adding keyboard navigation to SVG line charts"The full annotated build — making <circle> and <path> nodes focusable, wiring the arrow-key handler, styling the :focus-visible ring on SVG, and announcing each focused point — is developed step by step in the deep-dive: adding keyboard navigation to SVG line charts.
Key facts from that page:
- Put
tabindexon the<circle>(the point), not on the<path>(the connecting line); the line is decorative and carriesaria-hidden="true". - Roving
tabindex— one0, the rest-1— keeps the chart to a single tab stop, exactly as a data grid does. - The
:focus-visiblering must be an in-SVGstroke/rchange, not the UA outline, to avoid clipping and to guarantee 3:1 contrast.
<!-- Preview: a single focusable point from the deep-dive -->
<!-- SC 4.1.2: aria-label carries the value; SC 2.1.1: tabindex makes it reachable -->
<circle cx="220" cy="90" r="6" tabindex="-1"
role="graphics-symbol"
aria-label="Pro plan, Week 2, 1,388 users"></circle>
Testing checklist
Permalink to "Testing checklist"Automated
Permalink to "Automated"Keyboard-only
Permalink to "Keyboard-only"Screen reader manual
Permalink to "Screen reader manual"Related
Permalink to "Related"- Data visualization & chart alternatives — the text summary and structured table every chart needs underneath the interactive layer
- Adding keyboard navigation to SVG line charts — the full annotated build of focusable
<circle>points and the arrow-key handler - Generating accessible text alternatives for D3 charts — deriving the per-point strings reused as announcements
- Implementing roving tabindex for custom data grids — the single-tab-stop focus model the chart reuses
- Choosing between polite and assertive aria-live regions — selecting the politeness level for value announcements