Adding Keyboard Navigation to Canvas Charts

A <canvas> chart renders as a single opaque bitmap, so there is nothing for the Tab key to land on and arrow keys never reach your data points. This guide builds a parallel focusable layer of offscreen DOM proxies, wires arrow-key traversal across the series, and draws a focus ring back onto the canvas so sighted keyboard users can see where they are.

This is a companion guide to the keyboard navigation patterns section, which sits under the broader accessible interactive data visualization overview. If you are still choosing a rendering technology, the tradeoff that creates this whole problem — Canvas having no per-element DOM — is covered in SVG vs Canvas architecture.

Diagnostic checklist

Verify these root-cause hypotheses before writing traversal code:

How the parallel layer works

The fix is to keep the canvas purely visual and mirror each data point with a tiny, visually hidden, focusable DOM element positioned over the canvas. The DOM proxies carry the semantics (role, name, focus); the canvas carries the pixels.

Canvas plus proxy DOM layer A canvas bitmap underneath a transparent layer of focusable proxy buttons, one per data point, with arrow keys moving focus. canvas bitmap pixels only, no focus targets proxy DOM layer focused one focusable proxy per point
The canvas stays a pure bitmap while a transparent, visually hidden layer of focusable proxies provides the keyboard and screen-reader semantics.

Broken vs fixed

// ❌ BROKEN: a bare canvas — invisible to the keyboard entirely
function Chart({ points }: { points: DataPoint[] }) {
  const ref = useRef<HTMLCanvasElement>(null);
  useEffect(() => {
    const ctx = ref.current!.getContext("2d")!;
    drawPoints(ctx, points); // paints pixels, nothing focusable exists
  }, [points]);
  // No tabindex, no key handler, no per-point semantics.
  // Tab skips it; arrow keys do nothing; screen readers announce "canvas".
  return <canvas ref={ref} width={320} height={230} />;
}
// ✅ FIXED: canvas for pixels + a focusable proxy per point + arrow traversal
interface DataPoint { id: string; label: string; x: number; y: number; value: number; }

function Chart({ points }: { points: DataPoint[] }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [focused, setFocused] = useState<number>(-1); // index of focused point

  useEffect(() => {
    const ctx = canvasRef.current!.getContext("2d")!;
    drawPoints(ctx, points);
    if (focused >= 0) drawFocusRing(ctx, points[focused]); // ring follows focus
  }, [points, focused]);

  const move = (delta: number) => {
    // A11Y: clamp so arrow keys never escape the dataset bounds
    setFocused((i) => Math.max(0, Math.min(points.length - 1, (i < 0 ? 0 : i) + delta)));
  };

  return (
    <figure style={{ position: "relative" }}>
      <canvas ref={canvasRef} width={320} height={230} aria-hidden="true" />
      {/* A11Y: each proxy is a real focusable element carrying name + role */}
      <ul role="list" className="sr-proxy-layer">
        {points.map((p, i) => (
          <li key={p.id}>
            <button
              type="button"
              tabIndex={focused === i || (focused < 0 && i === 0) ? 0 : -1} // roving tabindex
              aria-label={`${p.label}: ${p.value}`}
              onFocus={() => setFocused(i)}
              onKeyDown={(e) => {
                if (e.key === "ArrowRight") { move(1); e.preventDefault(); }
                if (e.key === "ArrowLeft") { move(-1); e.preventDefault(); }
                if (e.key === "Home") { setFocused(0); e.preventDefault(); }
                if (e.key === "End") { setFocused(points.length - 1); e.preventDefault(); }
              }}
            />
          </li>
        ))}
      </ul>
    </figure>
  );
}

The broken version is a dead end for non-mouse users. The fixed version makes each data point a <button> with an accessible name, uses a roving tabindex so only one proxy is in the tab order at a time, and redraws the focus ring on the canvas whenever the focused index changes.

Step-by-step fix

  1. Make the canvas non-semantic. Add aria-hidden="true" to the <canvas> so assistive tech ignores the bitmap. The accessible information lives entirely in the proxy layer. Type the ref as useRef<HTMLCanvasElement>(null).
  2. Build the proxy layer. Render a <ul role="list"> with one <button type="button"> per DataPoint. Position the layer absolutely over the canvas (position: relative on the figure, position: absolute; inset: 0 on the list) and place each proxy at its data coordinate.
  3. Hide the proxies visually but keep them focusable. Use a visually-hidden technique that does not use display: none or visibility: hidden (both remove focusability). A 1px clip pattern or opacity: 0 over a sized hit area works. Give each proxy the point’s pixel left/top.
  4. Name each proxy. Set aria-label={${p.label}: ${p.value}} so the screen reader announces the data, not “button”.
  5. Implement a roving tabindex. Keep tabIndex={0} on exactly one proxy (the focused one, defaulting to the first) and tabIndex={-1} on the rest. Track the active index in React state.
  6. Wire arrow keys. On keydown, map ArrowRight/ArrowLeft to +1/-1 index moves, Home/End to first/last, and call e.preventDefault() to stop page scroll. After updating state, call .focus() on the newly active proxy inside a useEffect.
  7. Draw the focus ring. In the canvas render effect, when focused >= 0, call drawFocusRing(ctx, points[focused]) to stroke a high-contrast ring around the active point so sighted keyboard users can track focus.

Verification

Confirm the navigation actually works with these checks:

  • Tab into the chart. Press Tab from the element before the chart; focus should land on the first data point and you should hear its label and value, not “canvas”.
  • Arrow traversal. Press ArrowRight repeatedly and assert focus advances one point at a time. Add console.assert(document.activeElement === proxyEls[focused], "focus desync") in the focus effect.
  • Focus ring is visible. Watch the canvas — a ring must appear on the active point and move with each keystroke. Take a visual diff between two adjacent focus states.
  • No scroll hijack. Arrow keys must not scroll the page; verify preventDefault ran by checking window.scrollY is unchanged.
  • Roving tabindex. Query document.querySelectorAll('[tabindex="0"]') inside the proxy layer and assert the result length is exactly 1.

Edge cases & gotchas

  • display:none kills focus. A visually-hidden helper that uses display:none or visibility:hidden removes elements from the tab order, so your proxies become unreachable. Use the 1px clip-rect or zero-opacity-over-sized-hit-area pattern instead.
  • Dense datasets. With tens of thousands of points, rendering a proxy per point is too heavy. Group points into bins or series and let arrow keys traverse summarized buckets; offer a data table fallback as covered in the screen-reader-friendly charts guides.
  • High-DPI scaling. When you scale the canvas for retina displays the focus-ring coordinates must use CSS pixels, not the scaled backing-store pixels, or the ring drifts off the point. Multiply by devicePixelRatio only on the drawing context, not on the proxy positions.

Frequently Asked Questions

Why not just add tabindex to the canvas element itself?

That gives you a single focus stop for the entire chart, not one per data point. Screen-reader users could focus the canvas but would still have no way to move between or hear individual values. The proxy layer is what exposes per-point semantics.

Do I need a proxy DOM node for every single point?

No. For large series, summarize. Provide one focusable proxy per logical group (a series, a bin, or a category) and let arrow keys traverse those, with an accessible data table as the exhaustive fallback. A proxy-per-point is only practical for small charts.

Will this hurt rendering performance?

The canvas itself renders exactly as before. The cost is the proxy DOM and the focus-ring redraw on each keystroke, which is one extra draw per key event — negligible compared with mouse-driven animation. Keep the proxy count bounded and you stay well inside the frame budget.

Should the proxies be buttons or use role=“img”?

Interactive proxies that users move between with arrows are best as <button> elements because they are natively focusable and operable. Reserve role="img" for static, non-interactive chart descriptions.