Legend Toggle
A standalone, controlled chart legend — aria-pressed chips in a roving-tabindex toolbar, double-click or Enter to isolate one series, show-all / hide-all, and a guard that keeps the last series from blanking the chart.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-legend-toggle.jsonPrompt
Build a React + TypeScript + Tailwind "ChartLegendToggle" component — a chart
legend lifted out of the chart, so one legend can drive several charts or sit
wherever the layout wants it. No chart library involved: it renders buttons and
emits a set of keys.
Contract
- forwardRef div. Props:
series: { key: string; label: string; color?: string }[]
visible?: string[] // controlled visible keys
defaultVisible?: string[] // uncontrolled initial, defaults to every key
onVisibleChange?: (visible: string[]) => void
minVisible?: number // default 1 — the guard
orientation?: "horizontal" | "vertical" // default "horizontal"
showActions?: boolean // default true — count line + bulk buttons
label?: string // accessible name of the toolbar
longPressMs?: number // default 450 — hold-to-isolate on touch
plus className and the rest of the div's props.
- Controlled and uncontrolled through one useControllableState-style hook, so
the component never branches on which mode it is in.
- The emitted array is ALWAYS in declared series order, never click order — the
value has to stay comparable and serialisable (URL state, saved views).
- series.color takes a bare token name ("--chart-1"), a var() call, or any CSS
colour, and falls back to var(--chart-N) cycling by DECLARED index — so
hiding a middle series never re-colours the survivors.
Behavior
- Click a chip: toggle that series. Double-click, Enter, or a long press
(longPressMs) isolates it — everything else off; repeating the isolate on the
series that is already alone brings all of them back.
- minVisible is the guard against the classic blank chart. Turning off the last
visible series is REFUSED, not ignored: the chip stays pressed and focusable,
and a polite live region names the series holding the chart up. minVisible=0
hands that decision to the host.
- That chip is marked aria-disabled only when it has no move left at all — i.e.
when isolating is off too. While isolate is available, Enter on the lone chip
still brings everything back, and announcing it as disabled would hide the
only way out from anyone reading the accessibility tree.
- Clamp minVisible into [0, series.length] (trunc; NaN falls back to 1): a guard
above the series count would lock every chip with no way out, and NaN would
compare false forever and let the legend go blank.
- Isolating leaves exactly one series on, so it is disabled entirely whenever
minVisible is above 1 — no half-working move, no silent no-op.
- Bulk actions: "Show all", and a hide button whose label states what the guard
allows ("Hide all" / "Hide all but one" / "Hide all but N"); it keeps the first
minVisible series in declared order and announces which ones stayed.
- The second click of a double click (event.detail above 1) must not toggle, and
the click that follows a completed long press must be swallowed — otherwise
the pointer sequence undoes the isolate it just performed.
- Uncontrolled mode reconciles a growing series list: keys that appear after
mount (a fetch resolves) join the visible set instead of rendering as hidden.
Controlled mode never does this — the parent owns the set.
- Keys the parent no longer lists neither render nor count towards the guard.
- Clear the notice timer and the long-press timer on unmount.
Rendering & styling
- Semantic tokens only: bg-card, bg-accent, text-foreground,
text-muted-foreground, text-destructive, border-input, ring-ring,
ring-offset-background. The only colour that comes from data is the swatch,
and it is a chart token (var(--chart-1..5)) applied through style, never a hex
literal. Chart tokens are never used as text colour.
- Accessibility: role="toolbar" + aria-orientation with a ROVING TABINDEX — one
tab stop for the whole legend, arrows along the axis (wrapping), Home / End to
the ends. A twelve-series legend must not cost twelve Tab presses.
Each chip is a button with aria-pressed, NOT role="checkbox": it reveals or
conceals something already on screen rather than collecting a value, and
"checked" is the wrong word for "shown". Space toggles (the native button
default), Enter isolates (preventDefault the implicit click).
Refusals and the isolate / show-all announcements go through one polite
role="status" region that is mounted for the whole life of the component — a
live region that appears together with its first message is usually not
announced at all.
- State is never colour alone: a shown chip has a filled swatch, a solid border
and plain text; a hidden one has a hollow swatch in the same colour, a dashed
border and a line-through label. It survives a greyscale screenshot.
- Horizontal legends wrap (flex-wrap) rather than scroll or shrink; labels
truncate with min-w-0 so one long series name cannot widen the row.
- The only transitions are colour transitions, gated on motion-safe.
Customization levers
- Density and shape: chip padding / text size (px-2 py-1 text-xs), swatch size
and radius (size-3 rounded-xs), gap between chips.
- Swatch form: swap the square for a stroke sample (an svg line carrying the
series' dash pattern) when the legend drives a line chart whose series differ
by dash as well as by hue.
- Bulk row: showActions={false} strips the count and the two buttons for a bare
legend; or keep the row and replace the count with your own summary (total of
the visible series, date range).
- Guard: minVisible=0 for hosts that render their own "all series hidden" empty
state; minVisible=2 for comparison views that are meaningless with a single
series (isolate switches itself off there).
- Isolate ergonomics: longPressMs for touch, or drop the pointer handlers
entirely if the surface is desktop-only.
- Colour: pass series.color per series to match whatever the chart plots with,
or omit it and let the var(--chart-N) cycle assign slots.
- Orientation: "vertical" for a legend column beside a chart; the arrow keys
follow the axis automatically.Concepts
- Legend as control — the legend is not a caption the chart prints; it is a control that owns series visibility and hands the answer back. Lifting it out is what lets one legend drive two charts, or sit somewhere the chart library would never place it.
- Isolate (solo) — "just this one" is the most common thing anyone wants from a legend, and clicking eleven series off to get there is not a feature. A double click,
Enter, or a hold gets there in one move; repeating it on the series that is already alone brings the rest back. - Minimum-visible guard — the failure this control exists to prevent is a reader switching everything off and staring at an empty chart. The last visible chip refuses out loud instead of silently doing nothing: still pressed, still focusable, with a live region naming the series that is holding the chart up.
- Pressed, not checked —
aria-pressedsays "shown";role="checkbox"would say "checked" and promise a form field. Different role, different component — the generic pressed-button group isbuttons/toggle-group. - Roving tabindex toolbar — one tab stop for the whole legend, arrows inside it. Twelve series must not cost twelve Tab presses on the way to the next control; that is exactly the trade
role="toolbar"makes. - Declared-order emission — the value comes back in the order the series were declared, never in click order, so it can be compared, diffed, put in a URL and restored without churning.
Combo Chart (Dual Axis)
A four-state bar-plus-line combo chart whose two axes are built from one shared row structure, so a zero reading lands on the same gridline in both units and no gridline is meaningful on only one scale.
Bar Race
A four-state bar chart race — rows re-rank by transform so an overtake glides instead of cutting, with play/pause, timeline scrubbing, speed gears and a declared top-N board.