Chart Legend
Charts

Chart Legend

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.

Install

npx shadcn@latest add @paragon/chart-legend

Also installs: popover

chart-legend.tsx

"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>
  );
}