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.
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.
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 |
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.
Related
Permalink to "Related"- Direct labelling instead of legends — when no legend is needed
- Linked chart and table highlighting — keeping views in sync
- Accessible stacked bar charts — series in stacks