Accessible Chart Legends as Toggle Filters

Permalink to "Accessible Chart Legends as Toggle Filters"

Many chart libraries make legend entries clickable: click “Mobile” and the mobile series disappears; click again and it returns. For mouse users this is handy. As implemented by default, though, legend items are often spans or SVG text with click handlers — not focusable, not named as controls, and with visibility shown only by greying the swatch. Keyboard users cannot use them, screen reader users do not know they exist, and nobody hears what the chart now shows.

This guide belongs to interactive chart controls.

Spec reference

Permalink to "Spec reference"
  • SC 4.1.2 Name, Role, Value (A) — interactive legend items need a button role, a name and a state (aria-pressed).
  • SC 2.1.1 Keyboard (A) — toggles reachable and operable by keyboard.
  • SC 1.4.1 Use of Color (A) — hidden series must be indicated by more than a grey swatch: strikethrough, icon change or text.
  • SC 4.1.3 Status Messages (AA) — “Showing Desktop and Tablet” after a toggle is a status message.
  • SC 2.5.8 Target Size (Minimum) (AA) — legend items are often small; give them 24×24 targets.
Clickable spans versus toggle buttons Comparison: clickable legend spans are unfocusable and stateless; toggle buttons are focusable, named with the series, expose pressed state and change visually beyond colour. Clickable spans versus toggle buttons✗ Clickable span or SVG textNot focusable"Mobile" read as textHidden shown by grey swatchChart changes silentlyTiny target✓ Toggle buttonIn tab order (or toolbar arrows)"Mobile, toggle button, pressed"Strikethrough and "hidden" text"Showing Desktop and Tablet"24 px minimum

When to use — and when not to

Permalink to "When to use — and when not to"

Make legends interactive when users genuinely need to compare subsets of series. Otherwise, prefer direct labels and a static legend (or none) — see direct labelling instead of legends.

Do not use the legend as the only way to filter series in a dashboard where filters exist elsewhere; keep one filter model. The misapplication: a library’s default clickable legend left enabled on every chart, with no keyboard support, so mouse users can hide series that keyboard users cannot restore.

Should the legend be interactive? Flow: do users need to compare subsets? If not, use direct labels and a static legend. If yes, make legend items toggle buttons with pressed state, group them, and announce visible series. Should the legend be interactive?Need to compare subsets?many overlapping seriesno: static legend or direct labelsToggle buttonsbutton with aria-pressednamed with the seriesGrouprole="group" with a label"Series shown"Announcevisible series after each togglestatus regionGuardlast series cannot be hiddenor explain empty chart

Annotated code example

Permalink to "Annotated code example"
<div role="group" aria-labelledby="legend-label" class="legend">
  <span id="legend-label" class="legend-title">Series shown</span>
  <!-- Toggle buttons (SC 4.1.2); swatch decorative; pattern + text change when hidden (SC 1.4.1) -->
  <button type="button" aria-pressed="true" data-series="desktop"><span class="swatch" aria-hidden="true"></span>Desktop</button>
  <button type="button" aria-pressed="true" data-series="mobile"><span class="swatch" aria-hidden="true"></span>Mobile</button>
  <button type="button" aria-pressed="false" data-series="tablet"><span class="swatch" aria-hidden="true"></span>Tablet <span class="state">(hidden)</span></button>
</div>
<p id="legend-status" role="status" class="visually-hidden"></p>
legend.addEventListener('click', (e) => {
  const btn = e.target.closest('button[data-series]'); if (!btn) return;
  const on = btn.getAttribute('aria-pressed') !== 'true';
  const visible = legend.querySelectorAll('[aria-pressed="true"]').length;
  if (!on && visible === 1) { status.textContent = 'At least one series must be shown.'; return; }  // guard
  btn.setAttribute('aria-pressed', String(on));
  btn.querySelector('.state')?.remove();
  if (!on) btn.insertAdjacentHTML('beforeend', ' <span class="state">(hidden)</span>');
  chart.setSeriesVisible(btn.dataset.series, on);
  const names = [...legend.querySelectorAll('[aria-pressed="true"]')].map(b => b.dataset.series);
  status.textContent = `Showing ${listFormat(names)}.`;                 // SC 4.1.3
});
.legend button[aria-pressed="false"] { text-decoration: line-through; }       /* not colour alone */
.legend button[aria-pressed="false"] .swatch { background: none; border: 2px dashed currentColor; }
.legend button { min-block-size: 24px; padding-inline: .5rem; }                   /* SC 2.5.8 */

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Event Expected announcement AT deviations
Tab to legend “Series shown, grouping, Desktop, toggle button, pressed” VoiceOver: “selected” for pressed in some versions
Enter / Space Toggle “not pressed”, then “Showing Desktop and Tablet.”
Toggle last visible Refused “At least one series must be shown.”
Chart point navigation afterwards Hidden series skipped Make sure keyboard navigation skips hidden series
Legend behaviours and their accessibility Matrix of legend implementations — static text legend, clickable spans, toggle buttons, toggle buttons plus solo action — against focusable, state exposed, announced effect and colour independence. Legend behaviours and their accessibilityFocusableState exposedEffect announcedNot colour-onlyStatic text legendN/AN/AN/AWith direct labelsClickable spansNoNoNoGrey swatchToggle buttonsYesaria-pressedIf addedStrikethroughToggle + isolateYesYesYesYes
Toggle buttons with announcements meet all four.

Integration context

Permalink to "Integration context"

Hidden series must disappear from keyboard navigation too — see adding keyboard navigation to SVG line charts. If the chart has a data table, decide whether hidden series are hidden there as well, and say so. The button group could also be a toolbar; see the toolbar pattern for grid actions for the arrow-key variant.

Gotchas

Permalink to "Gotchas"

“Show only” interactions. Many libraries isolate a series on double-click. Provide an explicit “Show only Mobile” action in a menu instead of a gesture.

Legends far from charts. Legends placed below long charts are easy to miss; place toggles before the chart in reading order or reference them in the chart description.

State in the URL. If series visibility affects shared analyses, persist it.

Common mistakes

Permalink to "Common mistakes"
  • Click handlers on non-focusable legend items.
  • Hidden state shown only by a grey swatch.
  • No announcement of what the chart now shows.
  • Allowing all series to be hidden, leaving an empty chart with no explanation.

Framework notes

Permalink to "Framework notes"

Highcharts’ accessibility module renders legend items as buttons with aria-pressed; keep it enabled and add a status message. Chart.js legends are drawn on canvas with click handling — disable onClick and render an HTML legend with buttons using the htmlLegend plugin pattern. ECharts legends support selectedMode; for accessibility, render your own HTML buttons that call dispatchAction({ type: 'legendToggleSelect', name }).

// Chart.js: HTML legend buttons drive visibility
btn.onclick = () => { const i = idx(btn.dataset.series); chart.setDatasetVisibility(i, !chart.isDatasetVisible(i)); chart.update(); };

Worked example

Permalink to "Worked example"

A marketing dashboard’s traffic chart had a canvas legend with eight channels, clickable only by mouse. A screen reader user could not hide any series and heard all eight in every point announcement. The team replaced it with an HTML group of toggle buttons with strikethrough for hidden series, a “Show only…” menu, announcements of visible series, and chart navigation that skips hidden series. Point announcements dropped from eight values to the two or three the user chose.

Testing checklist

Permalink to "Testing checklist"

Design system notes

Permalink to "Design system notes"

Provide a SeriesToggleGroup that renders the group, buttons, strikethrough styles and announcements, and connects to any chart through setSeriesVisible. Disable library canvas legends when it is used.

FAQ

Permalink to "FAQ"
Should legend toggles be one tab stop with arrows, or one per item?

Either is acceptable. For more than five or six series, a toolbar with arrow keys is kinder.

Is aria-pressed or aria-checked better?

aria-pressed on buttons is the most widely supported. A group of checkboxes is also fine.

Should the table hide series too?

If the table mirrors the chart, hide the matching columns and announce it. Otherwise keep the table complete and say so.

How do I indicate hidden series in forced colours?

Strikethrough and dashed outlines survive forced colours; grey swatches do not.

Do static legends need to be buttons?

No. Static legends are text; if series are labelled directly, they may be unnecessary.

What about legends with dozens of items?

Use a searchable list of checkboxes or a multi-select combobox instead.

Should legend order match series order in the chart?

Yes. Match the visual stacking or line order so users can relate toggles to what they see, and keep the same order in the data table’s columns.

Permalink to "Related"

← Back to Interactive Chart Controls