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).
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.
| Prop | Type | Purpose |
|---|---|---|
placement | 'top-right' | 3 other corners | Which plot corner the card anchors to. Default 'top-right'. |
items | LegendItemInput[] | Escape hatch — render explicit rows instead of the registry; works outside a container. |
onRowClick | (row) => void | Override the default select-toggle (e.g. a show/hide handler). |
onRowHover | (row | null) => void | Override the default hover-echo. |
theme | ChartTheme | For 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 data — rows (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.
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
defaultrole show the same swatch — because they draw the same. Give them distinctasroles 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.legendslot and resolved styles. - Storybook:
Legend— every state (swatch kinds, placements, dedup, opt-out, headless chip row).