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:

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.

Keyboard focus traversal across chart series and points A line chart plot area with two series drawn as connected points. Tab enters the chart at the first point of the upper series. Left and Right arrows move along a series through its categories. Up and Down arrows switch between the upper and lower series at the same category. Each focused point announces series, category, and value. Plot area — single Tab stop enters here Category axis → Left / Right arrows move along a series focused Left / Right along a series Up / Down switch series Home / End first / last point Esc exits chart Series A Series B

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 tabindex on the <circle> (the point), not on the <path> (the connecting line); the line is decorative and carries aria-hidden="true".
  • Roving tabindex — one 0, the rest -1 — keeps the chart to a single tab stop, exactly as a data grid does.
  • The :focus-visible ring must be an in-SVG stroke/r change, 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"

Permalink to "Related"

Back to Virtualization, Charts & Dynamic Data Displays