Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { useControllableState } from "@/registry/hooks/use-controllable-state"

/** How long an announcement holds the status line before the standing hint comes back. */
const NOTICE_MS = 5000

/** Hold-to-isolate window, for touch — where there is no double click. */
const DEFAULT_LONG_PRESS_MS = 450

/**

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-legend-toggle.json

Prompt

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 checkedaria-pressed says "shown"; role="checkbox" would say "checked" and promise a form field. Different role, different component — the generic pressed-button group is buttons/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.

On This Page