Selection & hover
Separate from the hover readout, a chart can
report which data mark was clicked or hovered — a bar, a scatter point, a
heat cell, a trace — so you can drive a detail panel, a linked list, or a
cross-highlight. This is data-mark selection; the annotation counterpart
(selecting a Region / Marker / Baseline) lives on the
annotations pages.
This page covers one click, one mark. Sweeping a range of marks, holding
several at once, and the SpanSelection currency that makes a large selection
cheap live on Sweeps & multi-select.
Chapter 6 doesn't cover this; it's reference-only.
One component: the gesture and the state it drives
<Selector> is a value/onChange pair for a chart's selection — selected
is the value, onSelect is the change notification, both on the same tag —
and it wraps what it applies to rather than sitting beside it:
<ChartContainer range={range} width={720}>
<Selector selected={sel} onSelect={setSel}>
<ChartRow height={180}>
<YAxis id="v" />
<Layers>
<BarChart series={s} column="v" axis="v" id="cpu" />
</Layers>
</ChartRow>
</Selector>
</ChartContainer>
Two rules:
1. Mounting <Selector> is what enables the plot gesture. With no selector
mounted, a click on the plot does nothing. Selection is a subsystem — modifiers,
a set, a dim slot, precedence against hover — and it should not switch itself on
just because a layer happened to be given an id.
2. It wraps what it applies to. Mount it as a direct child of
<ChartContainer> to enable every row, or inside one <ChartRow> to scope the
gesture to just that row — wrapping that row's <Layers>, so each
<YAxis> stays a direct child of the <ChartRow>.
Note the asymmetry: placement scopes the gesture, not the state. A
SelectInfo names a layer, not a row, so selected / hovered apply
chart-wide wherever the selector sits. One selector should own each of them per
chart; if two declare the same one, the first registered wins and dev warns.
Want controlled highlighting from outside the chart — a legend chip, an
external filter list — with the plot deliberately inert on click? Mount
<Selector enabled={false} selected={sel}> wrapping the same content.
enabled defaults to true; false turns off hit-testing and callbacks while
selected/hovered stay in effect.
Identity gates selectability
A layer is selectable only when it carries an id — the stable series
identity. Layers match a selection against their id (the series) and the
sample's key, so two series sharing a timestamp don't both light up, and a
pinned selection survives a data update (it keys on the stable id, not the
sample index). A layer with no id still renders and reads out under the
cursor, but is never hit-tested — so clicking it is a null hit, the same value
as clicking empty space, which is already the deselect path.
id is identity; the mount is enablement. Both are required.
If you set up selection but no layer carries an id, a dev-time warning says
nothing is selectable and names the layers that take one.
What participates
| Layer | Click-select | Hover-highlight | Sweep |
|---|---|---|---|
<BarChart> | mark | ✓ | 1-D (x, or y when horizontal) |
<ScatterChart> | mark | ✓ | 2-D (x + value) |
<HeatMap> | mark | ✓ | 2-D (x + rows) |
<BoxPlot> | mark | ✓ | 1-D |
<LineChart> | series-scoped | ✓ | span, no marks |
<AreaChart> | series-scoped | ✓ | span, no marks |
<BarList> / <BoxList> | row | ✓ | row range (drag or Shift+↑/↓) |
The two trace rows are the ones to read carefully — see a trace has no marks.
SelectInfo
interface SelectInfo {
id: string; // the layer's id — the SELECTION IDENTITY, stable across updates
key: number; // the clicked sample's key (epoch ms) — provenance, not identity
value: number; // the clicked sample's plotted value — provenance
color: string; // the mark's resolved colour
label: string; // display label (as ?? column ?? id)
mark?: string; // stable per-mark handle within the layer — a category's
// column name on a categorical axis, a stack segment's group,
// or a trace's series handle, where the layer id isn't enough
}
Compare marks with sameMark(a, b) rather than a.key === b.key. It checks
mark before falling back to key, which is the difference between working
and not on a categorical axis (every bar in a category group shares the layer
id), on a stack (segments share a key), on a heat map (a cell needs both its
column and its row), and on a trace (see below).
Modifiers: the library reports, you decide
onSelect's second argument carries the chord that was held:
interface SelectModifiers {
additive: boolean; // the platform "add to selection" chord — ⌘ on macOS, Ctrl elsewhere
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
altKey: boolean;
}
Prefer additive over the raw keys: it resolves the macOS/Windows split
once, in one place, rather than in every consumer that would get one of the two
wrong. pond applies no policy to the modifiers and holds no set — a chord
means whatever your handler decides. modifiers is undefined for a
programmatic select (a <Legend> chip), which is how you can tell a click
from a chip.
shift is already the drag chord for
regionSelectModifier on a continuous
axis, so a shift-click there may also be the start of a region drag. It is
reported for completeness; think before you give it a second meaning.
A trace has no marks
A line or area is selectable, but not the way a bar is — and the shape follows from taking "no marks" seriously rather than working around it.
A click on a trace commits a series-scoped SelectInfo: key and value
are NaN, because no sample was selected, and a stable mark carries the
identity. The NaN is not a gap in the payload — it is what that convention
already means, and the mark is the seam that makes the rest work. Without it a
toggle would be impossible: NaN !== NaN, so nothing could ever match a
series-scoped entry against itself.
<Selector
selected={sel}
onSelect={(hit) => {
if (hit === null) return setSel([]);
// Toggle: sameMark matches this trace against itself even though its
// key is NaN, and at a different x than the first click.
setSel((cur) =>
cur.some((e) => sameMark(e, hit))
? cur.filter((e) => !sameMark(e, hit))
: [hit],
);
}}
>
<ChartRow>…</ChartRow>
</Selector>
A line is hit within ~6px of its stroke; an area is hit anywhere inside its fill. An area's mark is its filled shape, so the whole shape is the target; a line is a stroke, so proximity to it is the test. Both measure to the segment, not to the nearest vertex, so a long shallow segment is hittable along its length rather than only near its ends.
Selected state is weight, never hue. A line's colour is how a reader tells
one series from another, so state cannot live there — a selected trace thickens
and keeps its colour, and the others recede in opacity with their hue intact.
This is the same channel rule <Candlestick> follows, and
theming has the tokens.
Controlled vs. uncontrolled
Omit <Selector selected> and a click manages selection internally — enough
for a self-contained chart. Pass selected (echoing onSelect back) when the
selection is shared state: a URL param, a sibling detail view, a selection
that should survive a re-render driven from elsewhere. hovered works the same
way for the transient highlight — pass it to light a mark from an out-of-band
hover (a legend, a table row), pairing with onHover to sync both directions.
onHover dedupes on key + label, so it fires on mark transitions, not
on every pointer move.
Sharp edges
- A selector must be mounted for a click to do anything — even a bare
<Selector />with no props. There's no container-level fallback. onHoverdedupes onkey+label, notvalue— on a live chart where a hovered bar's value changes it won't re-fire. Read the current value from your series, not from the lastonHoverpayload.- A trace click carries
NaN. If you displayhit.valueunconditionally you will renderNaNto a user. Branch on the layer, or onNumber.isNaN(hit.key). - Layers must be direct children of
<Layers>. A<Fragment>around a group of them swallows the z-order index, which decides both stacking and which layer wins a click; use a keyed array for conditional groups. Dev warns. - Mouse and keyboard. The plot gestures are pointer-driven; the
list family has full keyboard parity. Plot-level
keyboard access is tracked as
[PND-A11Y].
See also
- Sweeps & multi-select — selecting a range
of marks, holding several, and the
SpanSelectioncurrency. - Cursors & readouts — the hover readout (value under the pointer), distinct from clicking a mark.
- Lists —
<BarList>/<BoxList>row selection, the second interaction surface, with keyboard parity. - The annotation model — selecting
annotations (
onSelectAnnotation), the parallel system for marks you add. - Storybook:
Interactions/SelectorandInteractions/MultiSelector/Traces.