The shared interactive legend for wiring one set of series keys to any chart. Hovering a key dims its siblings in sync, clicking toggles a series with a draining swatch and struck-through label, and keys beyond a cap collapse into a "+N" popover.
npx shadcn@latest add @paragon/chart-legendAlso installs: popover
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/paragon/ui/popover";
export interface ChartLegendItem {
id: string;
label: string;
color: string;
/** Preformatted value shown after the label, e.g. "$42.1k". */
value?: string;
}
export interface ChartLegendProps
extends Omit<React.ComponentProps<"div">, "onChange" | "hidden"> {
items: ChartLegendItem[];
/** Controlled set of hidden series ids. */
hidden?: string[];
defaultHidden?: string[];
/** Fires with the full hidden set whenever a key is toggled. */
onHiddenChange?: (hidden: string[]) => void;
/** Fires with the hovered/focused id, or null — wire this to chart dimming. */
onHoverChange?: (id: string | null) => void;
/** Keys beyond this count collapse into a "+N" popover. */
maxVisible?: number;
/** Never allow the last visible series to be hidden. */
keepOne?: boolean;
size?: "sm" | "md";
}
/**
* The shared interactive legend primitive for wiring one set of series keys
* to any number of charts. Hovering a key dims its siblings and emits
* `onHoverChange` so charts can dim in sync; clicking toggles a series —
* the swatch drains hollow and refills through a scale transition, the
* label strikes through, and `onHiddenChange` reports the full hidden set.
* Keys beyond `maxVisible` collapse into a "+N" popover with the same
* toggle rows. Every key is a real button with `aria-pressed`, and dim
* states retarget mid-transition rather than restarting.
*/
export function ChartLegend({
items,
hidden: hiddenProp,
defaultHidden,
onHiddenChange,
onHoverChange,
maxVisible,
keepOne = true,
size = "md",
className,
...props
}: ChartLegendProps) {
const [hiddenState, setHiddenState] = React.useState<string[]>(
defaultHidden ?? [],
);
const hidden = hiddenProp ?? hiddenState;
const [hovered, setHovered] = React.useState<string | null>(null);
const setHover = (id: string | null) => {
setHovered(id);
onHoverChange?.(id);
};
const toggle = (id: string) => {
const isHidden = hidden.includes(id);
if (!isHidden && keepOne && hidden.length >= items.length - 1) return;
const next = isHidden ? hidden.filter((h) => h !== id) : [...hidden, id];
if (hiddenProp === undefined) setHiddenState(next);
onHiddenChange?.(next);
};
const cut = maxVisible !== undefined && items.length > maxVisible;
const inline = cut ? items.slice(0, maxVisible) : items;
const overflow = cut ? items.slice(maxVisible) : [];
const overflowHiddenCount = overflow.filter((i) =>
hidden.includes(i.id),
).length;
const sm = size === "sm";
const renderKey = (item: ChartLegendItem, inPopover = false) => {
const isHidden = hidden.includes(item.id);
const dimmed = hovered !== null && hovered !== item.id && !isHidden;
return (
<button
key={item.id}
type="button"
onClick={() => toggle(item.id)}
onPointerEnter={() => setHover(item.id)}
onPointerLeave={() => setHover(null)}
onFocus={() => setHover(item.id)}
onBlur={() => setHover(null)}
aria-pressed={!isHidden}
aria-label={`${item.label}${item.value ? `, ${item.value}` : ""}${
isHidden ? ", hidden" : ""
}`}
className={cn(
"pressable inline-flex items-center rounded-md text-muted-foreground outline-none transition-[color,opacity] duration-150 ease-out hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
sm ? "gap-1 px-1 py-0.5 text-[11px]" : "gap-1.5 px-1.5 py-1 text-xs",
inPopover && "w-full justify-start px-2 py-1.5",
)}
style={{ opacity: isHidden ? 0.45 : dimmed ? 0.45 : 1 }}
>
{/* Swatch: hollow ring that drains / refills through scale */}
<span
className={cn(
"relative shrink-0 rounded-[3px]",
sm ? "size-2" : "size-2.5",
)}
style={{ boxShadow: `inset 0 0 0 1.5px ${item.color}` }}
aria-hidden
>
<span
className="absolute inset-0 rounded-[2px] transition-transform duration-200 ease-(--ease-out)"
style={{
background: item.color,
transform: isHidden ? "scale(0)" : "scale(1)",
}}
/>
</span>
<span className={cn("truncate", isHidden && "line-through")}>
{item.label}
</span>
{item.value && (
<span
className={cn(
"font-medium text-foreground tabular-nums",
isHidden && "line-through opacity-60",
inPopover && "ml-auto pl-3",
)}
>
{item.value}
</span>
)}
</button>
);
};
return (
<div
data-slot="chart-legend"
role="group"
aria-label="Chart legend"
className={cn("flex flex-wrap items-center gap-x-1 gap-y-1", className)}
{...props}
>
{inline.map((item) => renderKey(item))}
{overflow.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label={`${overflow.length} more series`}
className={cn(
"pressable inline-flex items-center rounded-md text-muted-foreground outline-none transition-[color,background-color] duration-150 ease-out hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
sm ? "gap-1 px-1.5 py-0.5 text-[11px]" : "gap-1.5 px-2 py-1 text-xs",
)}
>
<span className="font-medium tabular-nums">+{overflow.length}</span>
{overflowHiddenCount > 0 && (
<span className="text-[10px] opacity-70 tabular-nums">
({overflowHiddenCount} off)
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-1">
<div className="flex flex-col">
{overflow.map((item) => renderKey(item, true))}
</div>
</PopoverContent>
</Popover>
)}
</div>
);
}