Skip to main content

Legend

A series key — one row per draw layer, each with a swatch of that layer's resolved style and its readout label. Because the swatch is the style the canvas actually drew with, the key can't drift from the plot — the thing an app-side legend only gets by hand-sharing a palette.

<Legend> is the batteries-included card. When your legend is a design of its own — a horizontal strip, a ticker-compare pair, values-in-the-legend — useChartLegend() hands you the same rows as data plus the hover/select sync, and you render it.

The card

Drop <Legend /> anywhere inside a <ChartContainer> (it reads the layer registry, not its own position) and it enumerates the registered layers — no props required. placement anchors it to a corner of the plot area (inset past the axis gutters, so it never sits over the y-axis labels).

src/examples/charts-legend.tsx
import {
BandChart,
ChartContainer,
Legend,
ChartRow,
Layers,
LineChart,
YAxis,
} from '@pond-ts/charts';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';
import { latencyPercentileBand } from './lib/gallery-fixtures';

/** The zero-config card: `<Legend />` enumerates the registered layers. Each
* row's swatch is the layer's *resolved* style (a translucent band, a solid
* line), so the key can never drift from the plot. `legend="…"` renames a
* row without touching its `as` style role. */
export default function ChartsLegend({ width }: { width: number }) {
const theme = useSiteChartTheme();
const band = latencyPercentileBand();

return (
<ChartContainer range={band.timeRange()} width={width} theme={theme}>
<ChartRow height={220}>
<YAxis id="ms" label="latency (ms)" width={56} />
<Layers>
<BandChart
series={band}
lower="p25"
upper="p75"
as="inner"
legend="p25–p75"
axis="ms"
/>
<LineChart
series={band}
column="p50"
as="primary"
legend="median"
axis="ms"
/>
<LineChart
series={band}
column="p95"
as="secondary"
legend="p95"
axis="ms"
/>
</Layers>
</ChartRow>
<Legend placement="top-right" />
</ChartContainer>
);
}

Each row's swatch speaks its mark's own vocabulary — a line's stroke (and dash), a band's translucent fill, a scatter dot, a candle's up/down pair. The label is the layer's readout identity (as ?? column) — the same string the hover readout shows — so a value under the cursor and its legend row always agree. legend="…" renames a row (here p50 → "median") without touching its as style role; legend={false} opts a layer out entirely.

Rows follow chart-row → declaration order, and two layers sharing an identity (id ?? label) collapse to one row — exactly as the tracker readout merges keys, so a line and its scatter overlay on the same series read as one entry.

A stacked BarChart is the one layer that contributes more than one row — it registers one per group (in stack order), each with that group's resolved fill, so the key enumerates the segments. The group names come from the data, so a legend="…" rename doesn't apply there; every other layer is one row.

Interactivity is id-gated

A row whose layer carries an id is interactive on the same contract as the marks: hovering the row echoes into the container's hovered channel (lighting the matching mark), and clicking toggles the container selection. Selection reads by contrast — the selected row stays bold at full opacity while every other row dulls (the ticker-compare treatment). Series show/hide is deliberately not built in (a legend mutating what's drawn would be a second styling channel); use onRowClick to wire your own visibility toggle.

PropTypePurpose
placement'top-right' | 3 other cornersWhich plot corner the card anchors to. Default 'top-right'.
itemsLegendItemInput[]Escape hatch — render explicit rows instead of the registry; works outside a container.
onRowClick(row) => voidOverride the default select-toggle (e.g. a show/hide handler).
onRowHover(row | null) => voidOverride the default hover-echo.
themeChartThemeFor standalone items mode outside a container (no frame to read one from).

The card's colours come from an optional theme.legend slot (background/border/text); absent it, they derive from the chip / axis tokens, so a hand-built theme needs no new fields.

Scope follows placement

At the container level <Legend> lists every row. Placed inside a <Layers>, it scopes to that <ChartRow> — listing only that row's layers and anchoring to that row's plot, the same way an annotation belongs to the row it sits in. A per-row legend on a stacked dashboard needs no prop, just placement:

<ChartContainer /* … */>
<ChartRow height={160}>
<YAxis id="px" />
<Layers>
<LineChart series={price} column="close" as="price" axis="px" />
<Legend /> {/* lists only this row */}
</Layers>
</ChartRow>
<ChartRow height={90}>
<YAxis id="vol" />
<Layers>
<BarChart series={price} column="volume" as="volume" axis="vol" />
<Legend /> {/* lists only this row */}
</Layers>
</ChartRow>
</ChartContainer>

useChartLegend() scopes the same way — called inside a <Layers> its rows are that row's.

Headless — useChartLegend()

When the card isn't the design you want, useChartLegend() returns the same entries as datarows (items grouped by chart row; flat is rows.flatMap((r) => r.items)) plus the verbs already wired to the chart — render anything. This example is a horizontal chip row above the plot, each chip showing a current-or-cursor value: the hook hands you cursorTime (the cursor's instant, or null when idle), you look the value up in your own series (series.nearest(cursorTime), else the latest sample). Hover the plot and the values track the cursor; click a chip to select.

src/examples/charts-legend-headless.tsx
import { TimeSeries } from 'pond-ts';
import {
ChartContainer,
ChartRow,
Layers,
ScatterChart,
useChartLegend,
YAxis,
} from '@pond-ts/charts';
import { useSiteChartTheme } from '@site/src/theme/useSiteChartTheme';

const MINUTE = 60_000;
const BASE = Date.UTC(2026, 0, 12, 9, 0, 0);

/** Two latency series on one axis — the source the custom chip row reads
* its current-or-cursor values from. */
function latencies() {
const rows = Array.from({ length: 80 }, (_, i) => [
BASE + i * MINUTE,
90 + 26 * Math.sin(i / 9),
150 + 30 * Math.sin(i / 7 + 1),
]) as [number, number, number][];
return new TimeSeries({
name: 'latency',
schema: [
{ name: 'time', kind: 'time' },
{ name: 'p50', kind: 'number' },
{ name: 'p95', kind: 'number' },
] as const,
rows,
});
}

/** A **custom** legend built on `useChartLegend()`: a horizontal chip row
* above the plot (aligned to it via `gutters`), each chip showing its
* current-or-cursor value — the hook's `cursorTime` (else the latest
* sample) looked up in the consumer's own series. Click a chip to toggle
* selection; hover the plot and the values track the cursor. */
export default function ChartsLegendHeadless({ width }: { width: number }) {
const theme = useSiteChartTheme();
const series = latencies();

function ChipRow() {
const { rows, gutters, cursorTime, hover, select } = useChartLegend();
// One chart row here → flatten the groups to a flat chip list.
const items = rows.flatMap((r) => r.items);
const anySelected = items.some((it) => it.selected);
// The hook hands over the cursor instant (null when not hovering); the
// consumer owns the series, so the value lookup is theirs. Each scatter's
// `id` is its column name, so the item identity keys straight into the data.
const valueAt = (id: string | undefined): number | undefined => {
if (id !== 'p50' && id !== 'p95') return undefined;
const e =
cursorTime !== null ? series.nearest(cursorTime) : series.last();
return e?.get(id) as number | undefined;
};

return (
<div
style={{
display: 'flex',
gap: 8,
padding: `0 ${gutters.right + 4}px 8px ${gutters.left + 4}px`,
}}
>
{items.map((item) => {
const color =
item.swatch.kind === 'scatter' ? item.swatch.color : '#888';
const v = valueAt(item.id);
return (
<button
key={
item.id !== undefined ? `${item.id} ${item.label}` : item.label
}
onPointerEnter={() => hover(item)}
onPointerLeave={() => hover(null)}
onClick={() => select(item)}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
font: '12px system-ui',
padding: '3px 10px',
borderRadius: 999,
border: `1px solid ${
item.selected ? color : 'var(--pond-viz-grid)'
}`,
background: 'var(--pond-surface)',
color: 'var(--pond-body)',
cursor: 'pointer',
opacity: anySelected && !item.selected ? 0.45 : 1,
fontWeight: item.selected ? 600 : 400,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: 999,
background: color,
}}
/>
{item.label}
{v !== undefined && (
<span style={{ opacity: 0.6 }}>{v.toFixed(0)}ms</span>
)}
</button>
);
})}
</div>
);
}

return (
<ChartContainer range={series.timeRange()} width={width} theme={theme}>
<ChipRow />
<ChartRow height={200}>
<YAxis id="ms" label="latency (ms)" width={56} />
<Layers>
{/* Scatter carries the `id` (selectable + the value key); `as`
picks the colour role, `legend` names the row. */}
<ScatterChart
series={series}
column="p50"
id="p50"
as="primary"
legend="p50"
axis="ms"
/>
<ScatterChart
series={series}
column="p95"
id="p95"
as="secondary"
legend="p95"
axis="ms"
/>
</Layers>
</ChartRow>
</ChartContainer>
);
}
interface ChartLegend {
// items grouped by chart row; a flat list is rows.flatMap((r) => r.items)
rows: LegendRow[]; // { rowKey, items: LegendItem[] }
// LegendItem = { label, swatch, id?, selected, hovered }
gutters: { left: number; right: number }; // align a custom layout to the plot
cursorTime: number | null; // the cursor's axis instant; null when idle
hover(item: LegendItemInput | null): void; // echo into the chart's hovered channel
select(item: LegendItemInput): void; // toggle the chart selection
}

<Legend> itself is built on this same core, so the card and a hand-rolled legend can never disagree about rows or sync semantics. Because a row's label matches the tracker's sample labels, folding live onTrackerChanged values into a custom legend is a label-keyed join — no extra plumbing.

Sharp edges

  • The swatch is the resolved style, warts and all. Two layers on the theme's default role show the same swatch — because they draw the same. Give them distinct as roles and the legend follows automatically.
  • A per-bin histogram (binColors) is one series — its single row shows the group's base fill, not the per-bin palette.
  • useChartLegend() must be under a <ChartContainer> (it reads the frame). To render your legend outside the chart's DOM box, portal it out — React context flows through portals — or use <Legend items> with rows you supply.

See also

  • Selection & hover — the id-gated contract the legend's interactions ride on.
  • Cursors & readouts — the hover readout whose labels the legend rows match.
  • Theming — the theme.legend slot and resolved styles.
  • Storybook: Legend — every state (swatch kinds, placements, dedup, opt-out, headless chip row).