Adding Keyboard Navigation to Canvas Bar Charts
Permalink to "Adding Keyboard Navigation to Canvas Bar Charts"A canvas chart is a picture. There is nothing inside it to focus, nothing to name, and no hit testing except what you write. Making one keyboard navigable therefore is not a matter of adding attributes to the canvas — it is a matter of building a parallel structure in the DOM that carries the interaction, and repainting the canvas to follow it. This page covers that structure, the announcement copy for a focused bar, and the focus indicator that has to be drawn rather than styled.
It is the canvas counterpart to adding keyboard navigation to SVG line charts, and it belongs to canvas and SVG chart keyboard interaction.
Spec reference
Permalink to "Spec reference"The HTML Living Standard describes fallback content inside a canvas element as the element’s fallback content, exposed to assistive technology when the canvas is not rendered. In practice fallback content is unreliable for interaction: browsers vary in whether they expose focusable fallback elements, and the canvas hit-testing API for focus rings is not widely supported.
The dependable pattern is therefore a sibling structure rather than fallback content: a list of real focusable elements, one per data point, positioned over the canvas or visually hidden, with the canvas marked aria-hidden="true".
SC 2.1.1 Keyboard (Level A) requires the interaction to exist at all; SC 1.1.1 Non-text Content (Level A) requires the chart to have a text alternative, which the parallel list plus a summary in the caption provides; SC 2.4.7 Focus Visible (Level AA) requires the focused bar to be visibly indicated, which is the repaint.
When to use — and when not to
Permalink to "When to use — and when not to"Use canvas when the data volume genuinely demands it — tens of thousands of points, or a chart that animates continuously. Below a few thousand marks, SVG is simpler and accessible almost for free, as the SVG page describes.
Do not build the parallel list for a decorative chart that duplicates a table already on the page. Mark it aria-hidden and let the table be the accessible representation.
Do not attempt hit testing against mouse coordinates as the keyboard path. The focused index is state you already own; deriving it from geometry is more code and more bugs.
Annotated code example
Permalink to "Annotated code example"<!-- SC 1.1.1: the figure carries the name and the summary -->
<figure>
<figcaption id="orders-caption">
Orders by month, 2026. Rose from 210 in January to 412 in June.
</figcaption>
<!-- SC 4.1.2: the canvas is a picture; the list is the widget -->
<canvas id="orders-canvas" width="720" height="320" aria-hidden="true"></canvas>
<ul class="chart-points visually-hidden" aria-labelledby="orders-caption">
<li><button type="button" data-index="0">January, 210 orders</button></li>
<li><button type="button" data-index="1">February, 264 orders</button></li>
<!-- one per data point, generated from the same array the chart draws -->
</ul>
</figure>
// SC 2.1.1 + 2.4.7: focus moves natively; the canvas is repainted to follow
const buttons = [...list.querySelectorAll('button')];
let focusedIndex = -1;
list.addEventListener('focusin', (e) => {
focusedIndex = Number(e.target.dataset.index);
draw(data, focusedIndex); // repaint with the highlight
});
list.addEventListener('keydown', (e) => {
let next = focusedIndex;
if (e.key === 'ArrowRight') next = Math.min(focusedIndex + 1, data.length - 1);
else if (e.key === 'ArrowLeft') next = Math.max(focusedIndex - 1, 0);
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = data.length - 1;
else return;
e.preventDefault();
buttons[next].focus(); // the browser announces the button name
});
function draw(data, highlight) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
data.forEach((d, i) => {
ctx.fillStyle = barColour;
ctx.fillRect(x(i), y(d.value), barWidth, height(d.value));
if (i === highlight) {
// SC 2.4.7: the focus indicator must be DRAWN — CSS cannot reach inside a canvas
ctx.lineWidth = 3;
ctx.strokeStyle = focusColour; // 3:1 against both the bar and the background
ctx.strokeRect(x(i) - 2, y(d.value) - 2, barWidth + 4, height(d.value) + 4);
}
});
}
Generating the button labels from the same array the chart draws from is what keeps the two representations in agreement. A hand-written list drifts the first time the data changes shape.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Assistive technology | On focusing a bar | Deviation |
|---|---|---|
| NVDA + Firefox | Reads the button name — category and value | None significant |
| JAWS + Chrome | Reads the button name and its position in the list | Verbose with long category names |
| VoiceOver + Safari | Reads the button name | May not scroll a visually hidden button into view |
| TalkBack + Chrome | Swipes through the buttons in order | Works well — the list is the accessible chart |
Integration context
Permalink to "Integration context"The parallel list is also the text alternative required by SC 1.1.1, which means a canvas chart built this way needs no separate hidden table — the list is the data, in order, with labels. Where the chart has several series, group the buttons by series with a labelled ul per series so the reader announces which one it is in.
If the chart updates live, regenerate the buttons from the same update that redraws the canvas, and follow the announcement discipline in real-time data stream announcements rather than announcing every repaint.
Gotchas
Permalink to "Gotchas"The visually hidden list is not scrolled into view. A clip-based hiding technique keeps the element at a fixed position, so the browser cannot scroll to it. Position each button over its bar instead, with transparent backgrounds, so scrolling and focus behave normally.
The canvas is not repainted on focus. The reader announces a bar the user cannot see highlighted, which is disorienting for a sighted keyboard user.
Focus indicator drawn at low contrast. The outline must clear 3:1 against both the bar and the chart background — measure it, as described in auditing focus indicator contrast for WCAG 2.4.11.
Buttons regenerated on every frame. Rebuilding the list during an animation destroys focus. Update text content in place instead.
FAQ
Permalink to "FAQ"Can a canvas element be made keyboard navigable directly?
Not usefully. A canvas is a single element that draws pixels; there is nothing inside it to focus, so keyboard navigation between data points has to be provided by real DOM elements. The established approach is a parallel list of focusable elements — one per data point — kept visually hidden, with the canvas repainted to show which one is focused.
Does the canvas need aria-hidden?
Yes, if a parallel accessible structure exists. Otherwise assistive technology may announce the canvas as an unlabelled graphic in addition to the list, which duplicates the content. Mark the canvas aria-hidden=“true” and let the parallel list and the figcaption carry the meaning.
How should the focused bar be indicated visually?
Repaint it. Because the focusable element is not the canvas, the browser draws no focus ring on the bar itself, so the highlight has to be part of the render. Draw a clear outline around the focused bar at 3:1 contrast against both the bar colour and the chart background, and keep the visually hidden button positioned over the bar so browser scrolling behaves.
Related
Permalink to "Related"- Canvas & SVG chart keyboard interaction — the parent guide and the two rendering models
- Adding keyboard navigation to SVG line charts — the same interaction where the elements already exist
- Data visualization & chart alternatives — the text alternative this list doubles as