# Chart
Line, area, bar and pie charts in SVG with lilac rules, 12px muted labels and a white floating tooltip.

Install: npx inkbox-ds add chart
Requires: theme, utils
Packages: none
Exports: ChartConfig, AreaChart, BarChart, ChartContainer, ChartLegend, ChartLegendContent, ChartStyle, ChartTooltip, ChartTooltipContent, LineChart, PieChart, useChart
Client boundary: yes
Reference: Extrapolated from the Inkbox console (Figma 427:2715): muted labels, lilac rules, floating panels and the status colors


## Usage
Use to show volume over time (messages sent, calls, webhook deliveries) or a share (deliveries by status). Name and color each series in a ChartConfig, wrap the chart in ChartContainer, and add ChartTooltip and ChartLegend. Keep to five series; the colors follow the series order.
- Avoid: Gradients other than the area fade
- Avoid: 3D or shadowed marks
- Avoid: More than five series
- Avoid: Colors outside the series tokens


## Examples
Example components: npx inkbox-ds add chart
[Preview](https://inkbox-ds.vercel.app/preview/chart/Preview) | [Line](https://inkbox-ds.vercel.app/preview/chart/Line) | [Area](https://inkbox-ds.vercel.app/preview/chart/Area) | [Pie](https://inkbox-ds.vercel.app/preview/chart/Pie)

examples/chart.tsx

```tsx
"use client";

import { AreaChart, BarChart, type ChartConfig, ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, LineChart, PieChart } from "@/components/ui/chart";

const MESSAGES = [
  { month: "January", email: 186, imessage: 80 },
  { month: "February", email: 305, imessage: 200 },
  { month: "March", email: 237, imessage: 120 },
  { month: "April", email: 173, imessage: 190 },
  { month: "May", email: 209, imessage: 130 },
  { month: "June", email: 264, imessage: 140 },
];

const config = {
  email: { label: "Email" },
  imessage: { label: "iMessage" },
} satisfies ChartConfig;

const short = (v: string) => v.slice(0, 3);

export function Preview() {
  return (
    <ChartContainer config={config} className="aspect-auto h-80">
      <BarChart data={MESSAGES} index="month" tickFormatter={short} aria-label="Messages sent per month by channel" />
      <ChartTooltip content={<ChartTooltipContent />} />
      <ChartLegend content={<ChartLegendContent />} />
    </ChartContainer>
  );
}

export function Line() {
  return (
    <ChartContainer config={config} className="aspect-auto h-80">
      <LineChart data={MESSAGES} index="month" tickFormatter={short} showYAxis dots aria-label="Messages sent per month" />
      <ChartTooltip content={<ChartTooltipContent indicator="line" />} />
      <ChartLegend content={<ChartLegendContent />} />
    </ChartContainer>
  );
}

export function Area() {
  return (
    <ChartContainer config={config} className="aspect-auto h-80">
      <AreaChart data={MESSAGES} index="month" tickFormatter={short} stacked aria-label="Messages per month, stacked by channel" />
      <ChartTooltip content={<ChartTooltipContent indicator="dashed" />} />
    </ChartContainer>
  );
}

const DELIVERIES = [
  { status: "delivered", count: 1240 },
  { status: "retrying", count: 86 },
  { status: "failed", count: 32 },
];

const deliveryConfig = {
  delivered: { label: "Delivered", color: "var(--ds-color-chart-series-1)" },
  retrying: { label: "Retrying", color: "var(--ds-color-chart-series-2)" },
  failed: { label: "Failed", color: "var(--ds-color-chart-series-3)" },
} satisfies ChartConfig;

export function Pie() {
  return (
    <ChartContainer config={deliveryConfig} className="mx-auto aspect-square max-h-72">
      <PieChart data={DELIVERIES} index="status" value="count" aria-label="Webhook deliveries by status" />
      <ChartTooltip content={<ChartTooltipContent hideLabel />} />
      <ChartLegend content={<ChartLegendContent />} />
    </ChartContainer>
  );
}

```

## Source

components/ui/chart.tsx

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

// Charts drawn in SVG (extrapolated from the Inkbox console, Figma 427:2715: its 12px muted labels, lilac rules, white
// floating panels and the purple, pink, blue, teal and green of its states). ChartContainer takes a config that names
// and colors each series (series-1 purple, then pink, avatar blue, info teal, success green) and holds the chart, its
// tooltip and its legend. Lines draw in from the left and bars rise from the axis once, on mount (700ms and 400ms, the
// strong ease-out); with reduced motion they fade in. Hovering or moving with the arrow keys shows the tooltip.

export type ChartConfig = Record<string, { label?: React.ReactNode; icon?: React.ComponentType<{ className?: string }>; color?: string }>;

type Datum = Record<string, string | number>;
type Active = { index: number; x: number; y: number } | null;
type ChartContextProps = {
  config: ChartConfig;
  data: Datum[];
  setData: (data: Datum[], index: string, keys: string[]) => void;
  index: string;
  keys: string[];
  active: Active;
  setActive: (active: Active) => void;
  hidden: Set<string>;
  toggle: (key: string) => void;
};

const ChartContext = React.createContext<ChartContextProps | null>(null);

function useChart() {
  const context = React.useContext(ChartContext);
  if (!context) throw new Error("useChart must be used within a <ChartContainer />");
  return context;
}

const SERIES = ["var(--ds-color-chart-series-1)", "var(--ds-color-chart-series-2)", "var(--ds-color-chart-series-3)", "var(--ds-color-chart-series-4)", "var(--ds-color-chart-series-5)"];
const colorOf = (config: ChartConfig, key: string, i: number) => `var(--color-${key}, ${config[key]?.color ?? SERIES[i % SERIES.length]})`;

/** Kept for API parity: ChartContainer already sets each series' color as --color-<key> on itself. */
function ChartStyle(_props: { id?: string; config: ChartConfig }) {
  return null;
}

function ChartContainer({ id, className, children, config, ...props }: React.ComponentProps<"div"> & { config: ChartConfig }) {
  const [data, setDataState] = React.useState<Datum[]>([]);
  const [index, setIndex] = React.useState("");
  const [keys, setKeys] = React.useState<string[]>([]);
  const [active, setActive] = React.useState<Active>(null);
  const [hidden, setHidden] = React.useState<Set<string>>(new Set());
  const setData = React.useCallback((d: Datum[], i: string, k: string[]) => {
    setDataState(d);
    setIndex(i);
    setKeys(k);
  }, []);
  const toggle = React.useCallback((key: string) => setHidden((h) => {
    const next = new Set(h);
    if (next.has(key)) next.delete(key);
    else next.add(key);
    return next;
  }), []);
  const style = Object.fromEntries(Object.entries(config).map(([key, v], i) => [`--color-${key}`, v.color ?? SERIES[i % SERIES.length]])) as React.CSSProperties;
  return (
    <ChartContext.Provider value={{ config, data, setData, index, keys, active, setActive, hidden, toggle }}>
      <div data-slot="chart" data-chart={id} style={style} className={cn("relative flex aspect-video w-full flex-col justify-center font-sans text-caption text-fg-muted", className)} {...props}>
        {children}
      </div>
    </ChartContext.Provider>
  );
}

function useSize<T extends HTMLElement>() {
  const ref = React.useRef<T>(null);
  const [size, setSize] = React.useState({ width: 0, height: 0 });
  React.useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    const observer = new ResizeObserver(([entry]) => setSize({ width: entry.contentRect.width, height: entry.contentRect.height }));
    observer.observe(el);
    return () => observer.disconnect();
  }, []);
  return [ref, size] as const;
}

const PAD = { top: 12, right: 12, bottom: 28, left: 12 };

function niceMax(max: number) {
  if (max <= 0) return 1;
  const step = 10 ** Math.floor(Math.log10(max));
  return Math.ceil(max / step) * step;
}

/** A smooth path through the points (monotone in x), or straight segments. */
function pathOf(points: [number, number][], curve: "monotone" | "linear") {
  if (!points.length) return "";
  if (curve === "linear" || points.length < 3) return points.map(([x, y], i) => `${i ? "L" : "M"}${x},${y}`).join("");
  let d = `M${points[0][0]},${points[0][1]}`;
  for (let i = 0; i < points.length - 1; i++) {
    const [x0, y0] = points[i - 1] ?? points[i];
    const [x1, y1] = points[i];
    const [x2, y2] = points[i + 1];
    const [x3, y3] = points[i + 2] ?? points[i + 1];
    const c1 = [x1 + (x2 - x0) / 6, y1 + (y2 - y0) / 6];
    const c2 = [x2 - (x3 - x1) / 6, y2 - (y3 - y1) / 6];
    d += `C${c1[0]},${c1[1]} ${c2[0]},${c2[1]} ${x2},${y2}`;
  }
  return d;
}

type CartesianProps = {
  data: Datum[];
  /** The key of each datum's label on the x axis (month, day). */
  index: string;
  /** The series to draw, in order; defaults to every key in the config. */
  keys?: string[];
  showGrid?: boolean;
  showXAxis?: boolean;
  showYAxis?: boolean;
  /** Format the x labels (e.g. the first three letters of a month). */
  tickFormatter?: (value: string) => string;
  valueFormatter?: (value: number) => string;
  className?: string;
  "aria-label"?: string;
};

function useCartesian(kind: "line" | "area" | "bar", { data, index, keys: keysProp, showYAxis }: CartesianProps, stacked: boolean) {
  const chart = useChart();
  const keys = React.useMemo(() => keysProp ?? Object.keys(chart.config), [keysProp, chart.config]);
  const { setData } = chart;
  React.useEffect(() => setData(data, index, keys), [data, index, keys, setData]);
  const [ref, size] = useSize<HTMLDivElement>();
  const visible = keys.filter((k) => !chart.hidden.has(k));
  const values = data.map((d) => (stacked ? visible.reduce((s, k) => s + Number(d[k] ?? 0), 0) : Math.max(0, ...visible.map((k) => Number(d[k] ?? 0)))));
  const max = niceMax(Math.max(0, ...values));
  const left = PAD.left + (showYAxis ? 32 : 0);
  const innerW = Math.max(0, size.width - left - PAD.right);
  // Lines and areas keep their first and last points (and labels) clear of the edges.
  const inset = kind === "bar" ? 0 : 16;
  const innerH = Math.max(0, size.height - PAD.top - PAD.bottom);
  const band = data.length ? innerW / data.length : 0;
  const xAt = (i: number) => (kind === "bar" ? left + band * i + band / 2 : left + inset + (data.length > 1 ? ((innerW - inset * 2) * i) / (data.length - 1) : innerW / 2 - inset));
  const yAt = (v: number) => PAD.top + innerH - (innerH * v) / max;
  return { chart, keys, visible, ref, size, max, left, innerW, innerH, band, xAt, yAt };
}

function Frame({ kind, props, geometry, children }: { kind: "line" | "area" | "bar"; props: CartesianProps; geometry: ReturnType<typeof useCartesian>; children: React.ReactNode }) {
  const { chart, ref, size, max, left, innerW, innerH, band, xAt, yAt } = geometry;
  const { data, index, showGrid = true, showXAxis = true, showYAxis = false, tickFormatter = (v: string) => v, valueFormatter = (v: number) => v.toLocaleString() } = props;
  const ticks = [0, 0.25, 0.5, 0.75, 1].map((t) => max * t);
  const pick = (clientX: number, rect: DOMRect) => {
    const x = clientX - rect.left;
    let best = 0;
    for (let i = 0; i < data.length; i++) if (Math.abs(xAt(i) - x) < Math.abs(xAt(best) - x)) best = i;
    return best;
  };
  const activate = (i: number) => chart.setActive({ index: i, x: xAt(i), y: PAD.top });
  const labelStep = Math.max(1, Math.ceil(data.length / Math.max(1, Math.floor(innerW / 56))));
  return (
    <div ref={ref} className={cn("relative h-full min-h-0 w-full flex-1", props.className)}>
      {size.width > 0 && (
        <svg
          width={size.width}
          height={size.height}
          role="img"
          tabIndex={0}
          aria-label={props["aria-label"] ?? "Chart"}
          className="block overflow-visible rounded-control outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring"
          onPointerMove={(e) => activate(pick(e.clientX, e.currentTarget.getBoundingClientRect()))}
          onPointerLeave={() => chart.setActive(null)}
          onBlur={() => chart.setActive(null)}
          onFocus={() => activate(chart.active?.index ?? 0)}
          onKeyDown={(e) => {
            if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
            e.preventDefault();
            const current = chart.active?.index ?? 0;
            activate(Math.min(data.length - 1, Math.max(0, current + (e.key === "ArrowRight" ? 1 : -1))));
          }}
        >
          {showGrid && ticks.map((t) => <line key={t} x1={left} x2={left + innerW} y1={yAt(t)} y2={yAt(t)} className="stroke-line-subtle" strokeWidth={1} strokeDasharray={t === 0 ? undefined : "3 3"} />)}
          {showYAxis && ticks.map((t) => (
            <text key={t} x={left - 8} y={yAt(t)} dy="0.32em" textAnchor="end" className="fill-fg-muted text-caption">
              {valueFormatter(t)}
            </text>
          ))}
          {chart.active && kind !== "bar" && <line x1={xAt(chart.active.index)} x2={xAt(chart.active.index)} y1={PAD.top} y2={PAD.top + innerH} className="stroke-line-field" strokeWidth={1} />}
          {chart.active && kind === "bar" && <rect x={xAt(chart.active.index) - band / 2} y={PAD.top} width={band} height={innerH} rx={6} className="fill-subtle" />}
          {children}
          {showXAxis && data.map((d, i) => (i % labelStep === 0 ? (
            <text key={i} x={xAt(i)} y={PAD.top + innerH + 18} textAnchor="middle" className="fill-fg-muted text-caption">
              {tickFormatter(String(d[index]))}
            </text>
          ) : null))}
        </svg>
      )}
    </div>
  );
}

function LineChart(props: CartesianProps & { curve?: "monotone" | "linear"; dots?: boolean }) {
  const g = useCartesian("line", props, false);
  const { curve = "monotone", dots = false } = props;
  return (
    <Frame kind="line" props={props} geometry={g}>
      {g.visible.map((key) => {
        const i = g.keys.indexOf(key);
        const points = props.data.map((d, n) => [g.xAt(n), g.yAt(Number(d[key] ?? 0))] as [number, number]);
        return (
          <g key={key} data-series={key}>
            <path d={pathOf(points, curve)} fill="none" stroke={colorOf(g.chart.config, key, i)} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" pathLength={1} strokeDasharray="1 1" className="animate-chart-line" />
            {(dots || g.chart.active) && points.map(([x, y], n) => (dots || g.chart.active?.index === n ? <circle key={n} cx={x} cy={y} r={g.chart.active?.index === n ? 4 : 3} fill="var(--ds-color-bg-default)" stroke={colorOf(g.chart.config, key, i)} strokeWidth={2} /> : null))}
          </g>
        );
      })}
    </Frame>
  );
}

function AreaChart(props: CartesianProps & { curve?: "monotone" | "linear"; stacked?: boolean }) {
  const { curve = "monotone", stacked = false } = props;
  const g = useCartesian("area", props, stacked);
  const base = props.data.map(() => 0);
  const id = React.useId().replace(/:/g, "");
  return (
    <Frame kind="area" props={props} geometry={g}>
      <defs>
        {g.visible.map((key) => (
          <linearGradient key={key} id={`${id}-${key}`} x1="0" x2="0" y1="0" y2="1">
            <stop offset="0%" stopColor={colorOf(g.chart.config, key, g.keys.indexOf(key))} stopOpacity={0.32} />
            <stop offset="100%" stopColor={colorOf(g.chart.config, key, g.keys.indexOf(key))} stopOpacity={0.02} />
          </linearGradient>
        ))}
      </defs>
      {g.visible.map((key) => {
        const i = g.keys.indexOf(key);
        const lows = stacked ? [...base] : props.data.map(() => 0);
        const highs = props.data.map((d, n) => lows[n] + Number(d[key] ?? 0));
        if (stacked) highs.forEach((h, n) => (base[n] = h));
        const top = highs.map((v, n) => [g.xAt(n), g.yAt(v)] as [number, number]);
        const bottom = lows.map((v, n) => [g.xAt(n), g.yAt(v)] as [number, number]).reverse();
        const line = pathOf(top, curve);
        const area = `${line}L${bottom.map(([x, y]) => `${x},${y}`).join("L")}Z`;
        return (
          <g key={key} data-series={key}>
            <path d={area} fill={`url(#${id}-${key})`} className="animate-fade-in" />
            <path d={line} fill="none" stroke={colorOf(g.chart.config, key, i)} strokeWidth={2} strokeLinecap="round" pathLength={1} strokeDasharray="1 1" className="animate-chart-line" />
          </g>
        );
      })}
    </Frame>
  );
}

function BarChart(props: CartesianProps & { stacked?: boolean; radius?: number }) {
  const { stacked = false, radius = 4 } = props;
  const g = useCartesian("bar", props, stacked);
  const gap = 4;
  const groupW = Math.max(0, g.band * 0.72);
  const barW = stacked ? groupW : Math.max(2, (groupW - gap * (g.visible.length - 1)) / Math.max(1, g.visible.length));
  return (
    <Frame kind="bar" props={props} geometry={g}>
      {props.data.map((d, n) => {
        let acc = 0;
        return (
          <g key={n}>
            {g.visible.map((key, k) => {
              const value = Number(d[key] ?? 0);
              const x = stacked ? g.xAt(n) - groupW / 2 : g.xAt(n) - groupW / 2 + k * (barW + gap);
              const y0 = g.yAt(acc);
              const y1 = g.yAt(acc + value);
              if (stacked) acc += value;
              const top = !stacked || k === g.visible.length - 1;
              return (
                <rect
                  key={key}
                  x={x}
                  y={y1}
                  width={barW}
                  height={Math.max(0, y0 - y1)}
                  rx={top ? Math.min(radius, barW / 2) : 0}
                  fill={colorOf(g.chart.config, key, g.keys.indexOf(key))}
                  style={{ transformOrigin: `0 ${g.yAt(0)}px`, animationDelay: `${n * 30}ms` }}
                  className="animate-chart-bar"
                />
              );
            })}
          </g>
        );
      })}
    </Frame>
  );
}

function PieChart({ data, index, value, innerRadius = 0.6, className, ...props }: { data: Datum[]; index: string; value: string; innerRadius?: number; className?: string; "aria-label"?: string }) {
  const chart = useChart();
  const { setData } = chart;
  React.useEffect(() => setData(data, index, [value]), [data, index, value, setData]);
  const [ref, size] = useSize<HTMLDivElement>();
  const shown = data.filter((d) => !chart.hidden.has(String(d[index])));
  const total = shown.reduce((s, d) => s + Number(d[value] ?? 0), 0) || 1;
  const r = Math.max(0, Math.min(size.width, size.height) / 2 - 8);
  const cx = size.width / 2;
  const cy = size.height / 2;
  let angle = -Math.PI / 2;
  return (
    <div ref={ref} className={cn("relative h-full min-h-0 w-full flex-1", className)}>
      {r > 0 && (
        <svg width={size.width} height={size.height} role="img" aria-label={props["aria-label"] ?? "Chart"} className="block animate-pop-in origin-center" onPointerLeave={() => chart.setActive(null)}>
          {shown.map((d) => {
            const key = String(d[index]);
            const n = data.indexOf(d);
            const sweep = (Number(d[value] ?? 0) / total) * Math.PI * 2;
            const a0 = angle;
            const a1 = angle + sweep;
            angle = a1;
            const ri = r * innerRadius;
            const large = sweep > Math.PI ? 1 : 0;
            const p = (a: number, rr: number) => `${cx + rr * Math.cos(a)},${cy + rr * Math.sin(a)}`;
            const dPath = sweep >= Math.PI * 2 - 1e-6
              ? `M${cx - r},${cy}a${r},${r} 0 1,0 ${r * 2},0a${r},${r} 0 1,0 ${-r * 2},0${ri ? `M${cx - ri},${cy}a${ri},${ri} 0 1,1 ${ri * 2},0a${ri},${ri} 0 1,1 ${-ri * 2},0` : ""}`
              : `M${p(a0, r)}A${r},${r} 0 ${large} 1 ${p(a1, r)}${ri ? `L${p(a1, ri)}A${ri},${ri} 0 ${large} 0 ${p(a0, ri)}` : `L${cx},${cy}`}Z`;
            return (
              <path
                key={key}
                d={dPath}
                fillRule="evenodd"
                fill={colorOf(chart.config, key, n)}
                stroke="var(--ds-color-bg-default)"
                strokeWidth={2}
                className={cn("transition-opacity duration-150 ease-out", chart.active && chart.active.index !== n && "opacity-60")}
                onPointerMove={(e) => {
                  const rect = e.currentTarget.ownerSVGElement!.getBoundingClientRect();
                  chart.setActive({ index: n, x: e.clientX - rect.left, y: e.clientY - rect.top });
                }}
              />
            );
          })}
        </svg>
      )}
    </div>
  );
}

type TooltipItem = { key: string; name: React.ReactNode; value: number; color: string };

/** Where the tooltip shows: pass `content` (usually <ChartTooltipContent />). It follows the hovered point. */
function ChartTooltip({ content, cursor: _cursor }: { content?: React.ReactElement<ChartTooltipContentProps>; cursor?: boolean }) {
  const chart = useChart();
  if (!chart.active || !chart.data.length) return null;
  const datum = chart.data[chart.active.index];
  if (!datum) return null;
  const pie = chart.keys.length === 1 && !(chart.keys[0] in chart.config) && String(datum[chart.index]) in chart.config;
  const payload: TooltipItem[] = pie
    ? [{ key: String(datum[chart.index]), name: chart.config[String(datum[chart.index])]?.label ?? String(datum[chart.index]), value: Number(datum[chart.keys[0]] ?? 0), color: colorOf(chart.config, String(datum[chart.index]), chart.active.index) }]
    : chart.keys.filter((k) => !chart.hidden.has(k)).map((k, i) => ({ key: k, name: chart.config[k]?.label ?? k, value: Number(datum[k] ?? 0), color: colorOf(chart.config, k, chart.keys.indexOf(k) ?? i) }));
  const element = content ?? <ChartTooltipContent />;
  return (
    <div
      data-slot="chart-tooltip"
      role="status"
      className="pointer-events-none absolute z-10 animate-fade-in"
      style={{ left: chart.active.x, top: chart.active.y, transform: `translate(${chart.active.x > 160 ? "calc(-100% - 12px)" : "12px"}, 0)` }}
    >
      {React.cloneElement(element, { payload, label: pie ? undefined : String(datum[chart.index]) })}
    </div>
  );
}

type ChartTooltipContentProps = React.ComponentProps<"div"> & {
  payload?: TooltipItem[];
  label?: string;
  indicator?: "dot" | "line" | "dashed";
  hideLabel?: boolean;
  hideIndicator?: boolean;
  labelFormatter?: (label: string) => React.ReactNode;
  formatter?: (value: number, name: React.ReactNode, key: string) => React.ReactNode;
};

/** The tooltip's panel: white, 8px corners, the floating shadow, the label in 13px medium ink, a row per series. */
function ChartTooltipContent({ payload = [], label, indicator = "dot", hideLabel = false, hideIndicator = false, labelFormatter, formatter, className, ...props }: ChartTooltipContentProps) {
  return (
    <div data-slot="chart-tooltip-content" className={cn("grid min-w-32 gap-1.5 rounded-control bg-surface px-3 py-2 text-caption leading-tight text-fg shadow-floating", className)} {...props}>
      {!hideLabel && label !== undefined && <p className="text-body-sm font-medium text-fg">{labelFormatter ? labelFormatter(label) : label}</p>}
      <div className="grid gap-1">
        {payload.map((item) => (
          <div key={item.key} className="flex items-center gap-2">
            {!hideIndicator && (
              <span
                aria-hidden
                className={cn("shrink-0", indicator === "dot" ? "size-2.5 rounded-xs" : "h-3 w-1 rounded-xs", indicator === "dashed" && "border border-dashed bg-transparent")}
                style={indicator === "dashed" ? { borderColor: item.color } : { background: item.color }}
              />
            )}
            {formatter ? formatter(item.value, item.name, item.key) : (
              <>
                <span className="text-fg-muted">{item.name}</span>
                <span className="ml-auto pl-3 font-medium text-fg tabular-nums">{item.value.toLocaleString()}</span>
              </>
            )}
          </div>
        ))}
      </div>
    </div>
  );
}

/** Where the legend shows: pass `content` (usually <ChartLegendContent />). */
function ChartLegend({ content, verticalAlign = "bottom" }: { content?: React.ReactElement<React.ComponentProps<typeof ChartLegendContent>>; verticalAlign?: "top" | "bottom" }) {
  const element = content ?? <ChartLegendContent />;
  return <div className={cn(verticalAlign === "top" ? "order-first pb-3" : "pt-3")}>{element}</div>;
}

/** A row of 12px muted labels with their color keys; each toggles its series when `interactive`. */
function ChartLegendContent({ className, hideIcon = false, interactive = true, ...props }: React.ComponentProps<"div"> & { hideIcon?: boolean; interactive?: boolean }) {
  const chart = useChart();
  const keys = chart.keys.length === 1 && !(chart.keys[0] in chart.config) ? chart.data.map((d) => String(d[chart.index])) : chart.keys;
  return (
    <div data-slot="chart-legend-content" className={cn("flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5", className)} {...props}>
      {keys.map((key, i) => {
        const item = chart.config[key];
        const Icon = item?.icon;
        const off = chart.hidden.has(key);
        const body = (
          <>
            {Icon && !hideIcon ? <Icon className="size-3.5" /> : <span aria-hidden className="size-2.5 rounded-xs" style={{ background: colorOf(chart.config, key, i) }} />}
            {item?.label ?? key}
          </>
        );
        return interactive ? (
          <button key={key} type="button" aria-pressed={!off} onClick={() => chart.toggle(key)} className={cn("inline-flex items-center gap-1.5 rounded-xs text-caption leading-tight text-fg-secondary outline-none transition-opacity duration-150 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring motion-reduce:transition-none", off && "opacity-40")}>
            {body}
          </button>
        ) : (
          <span key={key} className="inline-flex items-center gap-1.5 text-caption leading-tight text-fg-secondary">
            {body}
          </span>
        );
      })}
    </div>
  );
}

export { AreaChart, BarChart, ChartContainer, ChartLegend, ChartLegendContent, ChartStyle, ChartTooltip, ChartTooltipContent, LineChart, PieChart, useChart };

```
