Search topics
Chart legends a keyboard can't toggle
Clicking a legend entry hides its series, but the entries are drawn on the canvas or are list items, so Tab skips them and nothing says they can be pressed.
li.onclick = () => { The problem
Keyboard and screen reader users can't hide a series to read the others, and aren't told that the legend does anything.
A shop's dashboard shows orders per day as a bar chart, with this week's bars beside last week's. A legend names the two series, each beside a swatch of its colour. Click "Last week" and last week's bars disappear, so this week's can be read on their own; the legend entry is struck through until it is clicked again. Chart.js does this by default, with no code from the shop.
Put the mouse aside and press Tab, and focus goes from whatever is above the card straight to "View all orders" under the chart. The legend entries are never reached, so a keyboard user can't take last week's bars off. A screen reader user meets the same legend as nothing at all, when it is painted on the canvas, or as two lines of plain text, "This week" and "Last week", when it is built from list items. Neither gives the entries a role that says they can be pressed, or a state that says, after a press, which series is hidden.
WCAG 2.1.1 Keyboard asks that everything a page does with a pointer can also be done with a keyboard. Hiding a series is something this chart does, and only a click does it. In Chart.js's default legend, the entries are not elements at all: the library paints them onto the canvas and works out from the pointer's position which one was clicked. A canvas takes no focus by default, and nothing on it listens for keys. In the second build, the legend is HTML, as in Chart.js's own "HTML Legend" sample, which builds an li for each series and gives it an onclick and a pointer cursor. A click handler works on any element, but only links, buttons, form fields and a few other elements are in the Tab order by default, so the list items are skipped. Either way, only a pointer can hide a series. Failure F54 describes this with pointer-specific event handlers such as onmousedown; a click handler is not pointer-specific in itself, since a keyboard fires it on a button, but on an element that Tab never reaches only a pointer can fire it.
WCAG 4.1.2 Name, Role, Value asks that every control tells assistive technology what it is, what it is called and what state it is in. A legend entry that hides a series is a control with a state: its series is shown or hidden. Painted on a canvas, it has no name, no role and no state. Built from an li, it has a name, its text, but its role is a list item, and its state is only a line drawn through the text with text-decoration, a style, not a state that assistive technology is told. That is failure F59, which W3C writes for a div or span but which holds the same here: a script makes an element a control without giving it a control's role.
A legend that hides series is how people compare two lines on a busy chart, or read a small series dwarfed by a large one. Without it, the chart still shows everything at once, and the reading the legend was there to make easier stays hard.
2 of 10 groups affected
- No vision (affected)
- Low vision (not affected)
- Colour vision (not affected)
- No hearing (not affected)
- Hard of hearing (not affected)
- No speech (not affected)
- Motor (affected)
- Reach, strength (not affected)
- Cognitive (not affected)
- Seizures (not affected)
- Limited manipulation Keyboard and switch users press Tab past the chart and land on the link under it: the legend entries are never in the way, so last week's bars can't be taken off to read this week's on their own.
- Without vision A screen reader reads the canvas legend as nothing at all, and the HTML legend as two lines of text, "This week" and "Last week". No role says they can be pressed, and after a click no state says which series is now hidden.
A legend painted on a canvas leaves nothing in the page to check: axe sees a canvas, and if it has role="img" and a name, axe passes it. A list item with a click handler is valid markup, so axe passes that too; it doesn't look for click handlers. This audit's keyboard probe reports a legend entry as a finding when it sits in an element whose class or id names a legend, takes clicks through a pointer cursor or an onclick set from a script, and is never reached by Tab, which is how Chart.js's HTML legend sample builds it. A legend drawn on the canvas can't be found that way, so every chart a library drew that holds nothing that takes focus becomes a lead, for a person to click its legend and points and try the same with the keyboard. An AI assistant asked for a custom legend may copy the library's sample as it stands, li.onclick included.
Try it
Hide last week’s bars with the mouse, then try it with only the keyboard.
Broken 1: click “Last week” at the top of the chart. Then put the mouse aside and press Tab: focus goes past the legend to “View all orders”.
Broken 2: the legend is now under the chart. Click “Last week”, then try Tab, and read the panel: is it read as a button, and does anything say it is off?
Apply a fix. Tab to “Last week” and press Space, or Enter on a toggle button. The panel shows what a screen reader says for each entry.
Broken Can you fix this?
Orders per day
Reading on from the chart, a screen reader says:
- View all orders, link
The fix
Make each legend entry a control that says if it's on.
The legend Chart.js draws on the canvas
<section class="card"> <h2>Orders per day</h2> <canvas id="orders" role="img" aria-label="…"></canvas> <a href="/orders">View all orders</a></section>What is wrong2 points
- The legend is painted on the canvas, so Tab never stops on "This week" or "Last week", and no key hides a series.
- The entries are pixels, with no name, no role and no state for a screen reader to read.
A toggle button for each series
<section class="card"> <h2>Orders per day</h2> <canvas id="orders" role="img" aria-label="…"></canvas> <ul class="legend" id="legend" aria-label="Series shown"></ul> <a href="/orders">View all orders</a></section>How it is fixed5 steps
- Turn off the legend Chart.js draws on the canvas with
plugins.legend.display: false, so the only legend is the one in the page. - Put a
buttonin each list item, named by its series alone, "This week" or "Last week". Tab reaches it, and Enter and Space press it. - Give it
aria-pressed="true", and on every press set it to whether the series is now shown. A screen reader can say "Last week, toggle button, pressed", and "not pressed" once its bars are hidden. - Draw the line through a hidden series from
aria-pressed="false"instead of an inline style, so what is drawn and what is said come from one value. - Name the list "Series shown", so the buttons are heard as a set that says which series are on.
This fixes both Befores and passes 2.1.1 and 4.1.2. The legend keeps its look: a swatch and a name, struck through when off.
Why this fix: Use native HTML first
Both pass. The toggle buttons keep the legend as it was drawn, a swatch and a name, and add only the keyboard and the state, so they suit a chart where the legend is part of the design. Their state, pressed or not, is less familiar than a tick box, which is why the list's name says what pressed means. The checkboxes say plainly what pressing does, with a box everyone recognises, at the cost of a different look: choose them where the legend already works as a filter, such as a dashboard with several series to pick from.
Keep the name fixed. The button says "Last week" whether its series is shown or hidden, and only aria-pressed changes. Swapping the words to "Show last week" as well would tell a screen reader the state twice, in two ways that can contradict each other. The ARIA Authoring Practices' button pattern calls it critical that a toggle button's label does not change when its state changes.
Patching the list item. role="button", tabindex="0", aria-pressed and a key handler for Enter and Space on the li would also pass, but a button brings all of that with it, and the patch is easy to leave half done.
The last series left. With every series hidden, the chart is an empty grid. Keep at least one series on, by refusing the last press or by saying in the chart's place that no series is shown.
The chart's text alternative. Hiding a series changes what is drawn, not the data. Keep the chart's name and its table describing every series, as charts whose numbers a screen reader can't read sets them up, so a screen reader user doesn't need the legend to get at the numbers.
Other libraries. Highcharts' accessibility module brings its own keyboard navigation, which includes the legend, so check what it gives before building another. Libraries that draw their legend as div elements with a pointer cursor fail the same way as the list items here, and the same fixes apply.
The values a chart shows only in a tooltip on hover are covered in chart tooltips a keyboard can't open. Bars that filter the page when clicked are another form of the same failure, which no guide covers yet. Series told apart only by their colour, with no name beside the swatch, is a failure of 1.4.1 Use of Color, covered on a status marker in order statuses shown only by colour.
Verify the fix
4 checks, no mouse.
Test with: Keyboard, Screen reader
Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat whenever the chart component or its library is updated.
How screen readers handle aria-pressed
Limits
This is a starting pattern, not a guarantee for every chart. Chart is Chart.js, and setDatasetVisibility its own way to hide a series; another library's equivalent goes in its place. Screen readers word aria-pressed differently, and some say "toggle button" while others say "button" with "pressed" or "not pressed" after it. Test the complete dashboard with a keyboard and your target assistive technology.
This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.
References
EaseWeb service
Fixed it? Keep it from coming back.
I turn guides like this one into rules your coding agents follow and checks your CI runs. Then I retest by hand with a keyboard and screen readers, periodically.