Adding Keyboard Navigation to SVG Line Charts

Permalink to "Adding Keyboard Navigation to SVG Line Charts"

Keyboard navigation for an SVG line chart is the practice of making each plotted data point a real focus stop that a keyboard user can reach and a screen reader can read, so nobody needs a mouse and a hover tooltip to learn a value. The single failure it prevents is the silent chart: a line renders, mouse users hover for detail, and keyboard and screen reader users get nothing but “Weekly active users, image”. This page is the concrete build behind the chart keyboard interaction model — the annotated HTML, JavaScript, and CSS for a two-series line chart.

Spec reference

Permalink to "Spec reference"

SVG shape elements (<circle>, <rect>, <path>) are focusable when given a tabindex attribute, and the browser exposes them to the accessibility API as it would any focusable element. There is no dedicated “data point” HTML element, so the accessible name comes from aria-label on the node or a <title> child element (the first child of the shape). The WAI-ARIA Graphics module adds role="graphics-symbol" for a single meaningful graphic and role="graphics-document" for the container; browser and AT support is partial, so these enhance rather than replace a tabindex + aria-label baseline.

Default behaviour without intervention: an svg element and its shapes are not in the tab order and are not individually exposed to AT. A bare interactive chart is therefore inoperable by keyboard — exactly what WCAG 2.1.1 Keyboard (Level A) and 4.1.2 Name, Role, Value (Level A) forbid for a control that conveys information.

When to use — and when not to

Permalink to "When to use — and when not to"

Use per-point focus when the chart is genuinely interactive: users need to read individual values, compare points, or the chart is the primary way that data is presented. A dashboard KPI line, a financial series, a survey-results plot — all warrant navigable points.

Do not make every point focusable when the chart is purely illustrative and a nearby table or text already carries the numbers. A sparkline decorating a stat tile does not need 30 tab stops; mark it aria-hidden="true" and let the adjacent value speak for it. The misuse to avoid is the opposite of the silent chart: a noisy chart where a decorative trendline injects dozens of meaningless focus stops that a keyboard user must tab past to reach the next real control. Match the interaction cost to the informational value.

For dense series (thousands of points), per-point DOM nodes can breach your DOM node budget; down-sample to a navigable subset or render only the active series’ points.

Annotated code example

Permalink to "Annotated code example"

Markup — points focusable, lines hidden

Permalink to "Markup — points focusable, lines hidden"
<!-- SC 1.3.1: figure/figcaption name the chart programmatically -->
<figure>
  <figcaption id="wau-cap">Weekly active users by plan</figcaption>
  <svg width="600" height="300" class="line-chart" aria-labelledby="wau-cap">
    <!-- SC 1.1.1: the connecting line is decorative — hide it from AT and tabbing -->
    <path d="M40,210 L160,180 L280,190 L400,120 L520,140"
          fill="none" stroke="#157a72" stroke-width="2" aria-hidden="true" />
    <!-- Data points: focusable, each named with series + category + value -->
    <!-- SC 2.1.1: tabindex makes points reachable; SC 4.1.2: aria-label = value -->
    <circle class="pt" cx="40"  cy="210" r="6" tabindex="0"  role="graphics-symbol"
            data-s="0" data-p="0" aria-label="Pro plan, Week 1, 1,240 users" />
    <circle class="pt" cx="160" cy="180" r="6" tabindex="-1" role="graphics-symbol"
            data-s="0" data-p="1" aria-label="Pro plan, Week 2, 1,388 users" />
    <circle class="pt" cx="280" cy="190" r="6" tabindex="-1" role="graphics-symbol"
            data-s="0" data-p="2" aria-label="Pro plan, Week 3, 1,301 users" />
    <!-- Second series, dashed line hidden the same way -->
    <path d="M40,250 L160,240 L280,255 L400,220 L520,235"
          fill="none" stroke="#2d7a3a" stroke-width="2"
          stroke-dasharray="6 4" aria-hidden="true" />
    <circle class="pt" cx="40"  cy="250" r="6" tabindex="-1" role="graphics-symbol"
            data-s="1" data-p="0" aria-label="Free plan, Week 1, 3,010 users" />
    <circle class="pt" cx="160" cy="240" r="6" tabindex="-1" role="graphics-symbol"
            data-s="1" data-p="1" aria-label="Free plan, Week 2, 3,102 users" />
    <circle class="pt" cx="280" cy="255" r="6" tabindex="-1" role="graphics-symbol"
            data-s="1" data-p="2" aria-label="Free plan, Week 3, 2,980 users" />
  </svg>
  <!-- SC 4.1.3: polite echo of the focused point (declared empty on load) -->
  <div id="chart-live" role="status" aria-live="polite" class="sr-only"></div>
</figure>

Only the first point carries tabindex="0"; every other point is -1. That is the roving tabindex pattern — the whole chart is one tab stop, and arrow keys move the 0. The connecting <path> elements are aria-hidden="true" so neither the tab order nor a screen reader treats the lines as content.

JavaScript — arrow-key traversal and announcement

Permalink to "JavaScript — arrow-key traversal and announcement"
// Build a 2-D lookup: grid[series][point] = the <circle> node
const chart = document.querySelector('.line-chart');
const live  = document.getElementById('chart-live');
const grid  = [];
chart.querySelectorAll('.pt').forEach(c => {
  const s = +c.dataset.s, p = +c.dataset.p;
  (grid[s] ||= [])[p] = c;
});

function focusPoint(node) {
  // Roving tabindex invariant: exactly one focusable node (SC 2.4.3)
  chart.querySelectorAll('.pt[tabindex="0"]')
       .forEach(n => n.setAttribute('tabindex', '-1'));
  node.setAttribute('tabindex', '0');
  node.focus();
  // SC 4.1.3: echo the value politely, clear-then-set so it always re-fires
  live.textContent = '';
  requestAnimationFrame(() => { live.textContent = node.getAttribute('aria-label'); });
}

chart.addEventListener('keydown', e => {
  const el = e.target;
  if (!el.classList.contains('pt')) return;
  let s = +el.dataset.s, p = +el.dataset.p;
  const lastP = grid[s].length - 1;
  switch (e.key) {
    // SC 2.4.3: Left/Right walk a series in category order
    case 'ArrowRight': p = Math.min(p + 1, lastP); break;
    case 'ArrowLeft':  p = Math.max(p - 1, 0);     break;
    // Up/Down switch series at the same category
    case 'ArrowUp':    s = Math.max(s - 1, 0);            break;
    case 'ArrowDown':  s = Math.min(s + 1, grid.length - 1); break;
    case 'Home':       p = 0;     break;
    case 'End':        p = lastP; break;
    // SC 2.1.2: Escape leaves the chart — never a keyboard trap
    case 'Escape':     document.getElementById('after-chart').focus(); return;
    default: return;               // other keys pass through untouched
  }
  e.preventDefault();              // stop the page from scrolling
  const target = grid[s][Math.min(p, grid[s].length - 1)];
  focusPoint(target);
});

CSS — a focus ring that survives the SVG viewport

Permalink to "CSS — a focus ring that survives the SVG viewport"
/* SC 2.4.7 Focus Visible + SC 1.4.11 Non-text Contrast (3:1) */
.line-chart .pt:focus-visible {
  outline: none;         /* UA outline clips inside SVG — replace it */
  stroke: currentColor;  /* currentColor reads in light and dark themes */
  stroke-width: 3;
  r: 9;                  /* enlarge the point so the ring is unmistakable */
  paint-order: stroke;   /* stroke drawn outside the fill, not over it */
}
/* Non-color distinction between series (SC 1.4.1): shape as well as hue */
.line-chart .pt[data-s="1"] { stroke-dasharray: 2 2; }

The UA outline is drawn in SVG coordinate space and is routinely clipped by the viewport or by a point near the edge. Removing it and thickening the node’s own stroke (plus a radius bump) paints the indicator inside the plot where it cannot be clipped, and currentColor guarantees it meets 3:1 contrast in both themes.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Key Action Expected announcement Failure indicator
Tab Enter chart at current point “Pro plan, Week 1, 1,240 users” Chart skipped; no point reachable
Right / Left Next / previous category, same series “Pro plan, Week 2, 1,388 users” Page scrolls; focus does not move
Down / Up Switch series at same category “Free plan, Week 1, 3,010 users” Series name missing from announcement
Home / End First / last point of series “…, Week 1…” / “…, Week 3…” Focus leaves the chart
Escape Exit to element after chart Next control’s name and role Focus trapped in the plot (fails 2.1.2)

Integration context

Permalink to "Integration context"

This build is the point-level detail under the canvas and SVG chart keyboard interaction cluster, which covers the SVG-versus-canvas decision and the role="img" trap. The per-point aria-label strings — “Pro plan, Week 2, 1,388 users” — should be generated by the same routine that builds your table and text summary, described in generating accessible text alternatives for D3 charts. Reusing one source string keeps the visual, keyboard, and screen reader representations in sync.

Gotchas

Permalink to "Gotchas"
  1. tabindex on the path, not the point. Making the connecting <path> focusable creates a stop with no discrete value to announce. Focus belongs on the <circle>; the line is aria-hidden="true".
  2. Forgetting preventDefault() on arrow keys. Without it, ArrowDown/ArrowUp scroll the page while your handler also moves focus — the plot scrolls out of view mid-navigation.
  3. Stale roving tabindex after a data refresh. If the chart re-renders and you rebuild the <circle> nodes, re-assert the invariant: exactly one point has tabindex="0". A refresh that leaves zero focusable points makes the chart unreachable; one that leaves several breaks the single-tab-stop contract.

FAQ

Permalink to "FAQ"
Should the tabindex go on the circle or the path in an SVG line chart?

Put tabindex on the <circle> that marks each data point, not on the <path> that connects them. The point is the meaningful, focusable unit that carries a value; the line is a visual join between points and should be marked aria-hidden="true" so screen readers and the tab sequence ignore it. Making the path focusable would create a stop with no discrete value to announce.

Why does the browser's default focus outline get clipped on SVG points?

The user-agent outline is drawn in the SVG element’s own coordinate space and is easily clipped by the viewport, by overflow:hidden, or by a point sitting near the plot edge. Replace it with an in-SVG indicator: remove the outline, then thicken the circle’s stroke and increase its radius on :focus-visible so the ring is painted inside the plot and always meets 3:1 non-text contrast.

Do I need a live region if each SVG point already has an aria-label?

Not strictly, but it is worth adding. NVDA and JAWS read the aria-label on SVG focus reliably, yet some VoiceOver builds announce SVG node focus inconsistently. A polite live region that echoes the focused point’s label is a low-cost belt-and-braces fallback that guarantees the value is spoken without stealing focus.

Permalink to "Related"

Back to Canvas & SVG Chart Keyboard Interaction