Skip to main content

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

LayerClick-selectHover-highlightSweep
<BarChart>mark1-D (x, or y when horizontal)
<ScatterChart>mark2-D (x + value)
<HeatMap>mark2-D (x + rows)
<BoxPlot>mark1-D
<LineChart>series-scopedspan, no marks
<AreaChart>series-scopedspan, no marks
<BarList> / <BoxList>rowrow 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.
  • onHover dedupes on key + label, not value — 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 last onHover payload.
  • A trace click carries NaN. If you display hit.value unconditionally you will render NaN to a user. Branch on the layer, or on Number.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