# Chart legends a keyboard can't toggle

Source: https://easeweb.dev/learn/chart-legends
Topics: Tables and data > Chart points, legends and tooltips work with a keyboard (WCAG 2.1.1, 4.1.2)
The fix: Turn off the legend the library draws on its canvas and build it in HTML. Either make each entry a button with aria-pressed set while its series is shown, or a checkbox in a fieldset, checked while its series is shown; both take the keyboard and say their state.
Test with: keyboard, screen reader
References: https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F54 https://www.w3.org/WAI/WCAG22/Techniques/failures/F59 https://www.w3.org/WAI/ARIA/apg/patterns/button/ https://www.chartjs.org/docs/latest/configuration/legend.html https://www.chartjs.org/docs/latest/samples/legend/html.html

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.

## What happens

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.

## Why it fails

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.

## Who is affected

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.

## What automation and AI miss

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.

## Before: the legend Chart.js draws on the canvas

```html
<section class="card">
  <h2>Orders per day</h2>
  <canvas id="orders" role="img" aria-label="…"></canvas>
  <a href="/orders">View all orders</a>
</section>
```

```css
.legend {
  display: flex;
  gap: 16px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.swatch {
  display: inline-block;
  width: 12px;
  height: 12px;
  margin-inline-end: 6px;
}
```

```js
const chart = new Chart(document.getElementById('orders'), {
  type: 'bar',
  data: {
    labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
    datasets: [
      { label: 'This week', data: [12, 15, 14, 18, 24, 31, 20], backgroundColor: '#4a86d4' },
      { label: 'Last week', data: [10, 13, 15, 14, 20, 26, 22], backgroundColor: '#a9c4ea' }
    ]
  }
});
```

- 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.

## Before: an HTML legend built from list items

```html
<section class="card">
  <h2>Orders per day</h2>
  <canvas id="orders" role="img" aria-label="…"></canvas>
  <ul class="legend" id="legend"></ul>
  <a href="/orders">View all orders</a>
</section>
```

```css
.legend {
  display: flex;
  gap: 16px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.swatch {
  display: inline-block;
  width: 12px;
  height: 12px;
  margin-inline-end: 6px;
}
```

```js
const chart = new Chart(document.getElementById('orders'), {
  type: 'bar',
  data: {
    labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
    datasets: [
      { label: 'This week', data: [12, 15, 14, 18, 24, 31, 20], backgroundColor: '#4a86d4' },
      { label: 'Last week', data: [10, 13, 15, 14, 20, 26, 22], backgroundColor: '#a9c4ea' }
    ]
  },
  options: { plugins: { legend: { display: false } } }
});

const legend = document.getElementById('legend');
chart.data.datasets.forEach((dataset, i) => {
  const li = document.createElement('li');
  const swatch = document.createElement('span');
  swatch.className = 'swatch';
  swatch.style.background = dataset.backgroundColor;
  li.append(swatch, dataset.label);
  li.style.cursor = 'pointer';
  li.onclick = () => {
    chart.setDatasetVisibility(i, !chart.isDatasetVisible(i));
    chart.update();
    li.style.textDecoration = chart.isDatasetVisible(i) ? '' : 'line-through';
  };
  legend.append(li);
});
```

The legend is now in the page, with words a screen reader can reach. Taking clicks on list items is where it fails:

- Tab never stops on an `li`, so the keyboard still can't hide a series.
- Each entry is read as a list item, "This week" or "Last week", with no role that says it can be pressed.
- The line through a hidden series is only a style, so no state says it is hidden.

## After: a toggle button for each series

```html
<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>
```

```css
.legend {
  display: flex;
  gap: 16px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.swatch {
  display: inline-block;
  width: 12px;
  height: 12px;
  margin-inline-end: 6px;
}
.legend button {
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  cursor: pointer;
}
.legend [aria-pressed="false"] {
  text-decoration: line-through;
}
```

```js
const chart = new Chart(document.getElementById('orders'), {
  type: 'bar',
  data: {
    labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
    datasets: [
      { label: 'This week', data: [12, 15, 14, 18, 24, 31, 20], backgroundColor: '#4a86d4' },
      { label: 'Last week', data: [10, 13, 15, 14, 20, 26, 22], backgroundColor: '#a9c4ea' }
    ]
  },
  options: { plugins: { legend: { display: false } } }
});

const legend = document.getElementById('legend');
chart.data.datasets.forEach((dataset, i) => {
  const li = document.createElement('li');
  const button = document.createElement('button');
  const swatch = document.createElement('span');
  swatch.className = 'swatch';
  swatch.style.background = dataset.backgroundColor;
  button.type = 'button';
  button.setAttribute('aria-pressed', 'true');
  button.append(swatch, dataset.label);
  button.addEventListener('click', () => {
    const shown = !chart.isDatasetVisible(i);
    chart.setDatasetVisibility(i, shown);
    chart.update();
    button.setAttribute('aria-pressed', String(shown));
  });
  li.append(button);
  legend.append(li);
});
```

1. 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.
2. Put a `button` in each list item, named by its series alone, "This week" or "Last week". Tab reaches it, and Enter and Space press it.
3. 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.
4. 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.
5. 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.

## After: a checkbox for each series

```html
<section class="card">
  <h2>Orders per day</h2>
  <canvas id="orders" role="img" aria-label="…"></canvas>
  <fieldset class="legend" id="legend">
    <legend>Show</legend>
  </fieldset>
  <a href="/orders">View all orders</a>
</section>
```

```css
.legend {
  display: flex;
  gap: 16px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.swatch {
  display: inline-block;
  width: 12px;
  height: 12px;
  margin-inline-end: 6px;
}
.legend {
  border: 0;
}
.legend legend {
  float: left;
  padding: 0;
}
.legend input:not(:checked) + .swatch {
  opacity: 0.3;
}
```

```js
const chart = new Chart(document.getElementById('orders'), {
  type: 'bar',
  data: {
    labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
    datasets: [
      { label: 'This week', data: [12, 15, 14, 18, 24, 31, 20], backgroundColor: '#4a86d4' },
      { label: 'Last week', data: [10, 13, 15, 14, 20, 26, 22], backgroundColor: '#a9c4ea' }
    ]
  },
  options: { plugins: { legend: { display: false } } }
});

const legend = document.getElementById('legend');
chart.data.datasets.forEach((dataset, i) => {
  const label = document.createElement('label');
  const box = document.createElement('input');
  const swatch = document.createElement('span');
  swatch.className = 'swatch';
  swatch.style.background = dataset.backgroundColor;
  box.type = 'checkbox';
  box.checked = true;
  label.append(box, swatch, dataset.label);
  box.addEventListener('change', () => {
    chart.setDatasetVisibility(i, box.checked);
    chart.update();
  });
  legend.append(label);
});
```

1. Turn off the canvas legend with `plugins.legend.display: false`.
2. Put a `fieldset` where the legend was, with the `legend` "Show", so the boxes are heard as "Show, group".
3. Give each series a checkbox in a `label` with its swatch and its name. Tab reaches each one, Space ticks and unticks it, and a screen reader says "This week, checkbox, checked".
4. Show or hide the series from the box's `checked` state on every change, and fade the swatch of an unticked series from `:checked`, so the box, the bars and the swatch can't disagree.

This fixes both Befores and passes 2.1.1 and 4.1.2. The resting look changes: each entry gains a tick box, and the legend reads as a filter above or under the chart.

## Which option to use

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.

## Implementation decisions

**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](/learn/chart-alternatives) 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](/learn/chart-tooltips). 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](/learn/status-colours).

## Verify the fix

1. In the code, find the legend: confirm the library's own legend is turned off, and that each entry is a `button` with `aria-pressed` or a checkbox, with no `li`, `div` or `span` taking clicks.
2. Put the mouse aside, Tab to each entry and press it: Enter or Space for a button, Space for a checkbox. Its series hides, and comes back on the next press.
3. With a screen reader, Tab to each entry: it is announced with its series' name, as a toggle button or a checkbox, and as pressed or checked while its series is shown.
4. Press an entry and listen: the new state is said, and after a reload, or a change of range that redraws the chart, the state still matches the bars.

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.

## 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.

## What screen readers do (tested by hand by EaseWeb)

`aria-pressed`: Makes a button a toggle that is on or off.

| Screen reader | Browser and system | Press it | Tab to it |
| --- | --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested | Not tested |
| NVDA | Chrome, Windows 11 | Not tested | Not tested |
| JAWS | Edge, Windows 11 | Not tested | Not tested |
| NVDA | Firefox, Windows 11 | Not tested | Not tested |
| NVDA | Edge, Windows 11 | Not tested | Not tested |
| JAWS | Firefox, Windows 11 | Not tested | Not tested |
| VoiceOver | Safari, macOS | Not tested | Not tested |
| VoiceOver | Safari, iOS | Not tested | Not tested |
| TalkBack | Chrome, Android | Not tested | Not tested |

https://easeweb.dev/learn/screen-reader-support

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
