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.
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
- 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 asuseRef<HTMLCanvasElement>(null). - Build the proxy layer. Render a
<ul role="list">with one<button type="button">perDataPoint. Position the layer absolutely over the canvas (position: relativeon the figure,position: absolute; inset: 0on the list) and place each proxy at its data coordinate. - Hide the proxies visually but keep them focusable. Use a visually-hidden technique that does not use
display: noneorvisibility: hidden(both remove focusability). A 1px clip pattern oropacity: 0over a sized hit area works. Give each proxy the point’s pixelleft/top. - Name each proxy. Set
aria-label={${p.label}: ${p.value}}so the screen reader announces the data, not “button”. - Implement a roving tabindex. Keep
tabIndex={0}on exactly one proxy (the focused one, defaulting to the first) andtabIndex={-1}on the rest. Track the active index in React state. - Wire arrow keys. On
keydown, mapArrowRight/ArrowLeftto+1/-1index moves,Home/Endto first/last, and calle.preventDefault()to stop page scroll. After updating state, call.focus()on the newly active proxy inside auseEffect. - Draw the focus ring. In the canvas render effect, when
focused >= 0, calldrawFocusRing(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
preventDefaultran by checkingwindow.scrollYis unchanged. - Roving tabindex. Query
document.querySelectorAll('[tabindex="0"]')inside the proxy layer and assert the result length is exactly 1.
Edge cases & gotchas
display:nonekills focus. A visually-hidden helper that usesdisplay:noneorvisibility:hiddenremoves 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
devicePixelRatioonly 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.
Related
- Keyboard navigation patterns — the parent section for keyboard interaction techniques.
- Accessible tooltips triggered by keyboard focus — show point details when a proxy receives focus.
- SVG vs Canvas architecture — why Canvas has no per-element DOM in the first place.