Interaction

What the chart does under a pointer or a keyboard — the crosshair, the focused column, and the tooltip that follows it.

Tooltips

Every tooltip is styled as a Popover, with the focused position as its header and one row per series beneath it.

A header naming a date matches how far apart the data sits: daily points are named by their weekday — Monday, July 20 — points less than a day apart carry the time, weekly ones stay short, and monthly ones name the month in full with its year — January 2026. Elsewhere the year appears only where the series crosses one; a month always carries it, since January alone names a month in no particular year.

These headers are written in US English wherever the chart is opened, as is every other figure the chart formats for itself — see How ticks are written.

When a tooltip needs figures the chart can't derive, render the inside yourself. React takes a renderTooltipContent function; Ember takes a :tooltipContent block. Both receive the focused points - each carrying its original row - and the defaultBody, so you can add to the default rows instead of replacing them:

Loading editor

To replace the surface as well, use renderTooltip (React) or the :tooltip block (Ember). The chart still decides when the tooltip is open and where it sits; the styling becomes yours.

Reading fields off the original row

Each point carries the row it was drawn from as datum. In React, name the row's type on the chart to have it typed rather than unknown:

interface ClientRow {
	day: string;
	client: string;
	opens: number;
}

<Chart<ClientRow>
	ariaLabel="Opens by email client"
	marks={marks}
	renderTooltipContent={({ points }) => <Text>{points[0]?.datum.client}</Text>}
/>;

Annotating the render function with ChartTooltipRenderContext<ClientRow> and passing it in does the same thing. Left off, datum is unknown; the rest of the point is typed as before.

What order the rows read in

Rows read in the order the chart declares them: marks in the order they are drawn, and within a mark, series in the order they first appear — the same order the legend reads in. A row stays where the reader last found it however the numbers move, so a tooltip read at one position can be compared against the next.

These three channels take four different rankings over six months — SMS leads in January and trails by June. Walk the chart and the rows hold still while the figures beside them move:

Loading editor

tooltipSeriesOrder="position" orders by where the focused points sit on the plot instead — top to bottom on a vertical chart, left to right on a horizontal one. That ranks the series at the position under the pointer, which is worth having on a chart read one position at a time. The cost is the rows changing places as the reader moves along the plot — the same six months, reordering under the pointer four times:

Loading editor

Hover and focus

Hovering a chart — or arrowing through it from the keyboard — focuses the whole column at the pointer, one point per series. The chart marks it with a full-height crosshair, a dot on every focused series, and the tooltip, which follows the pointer. Anywhere in the plot focuses the nearest column, so the whole width of a bar is a target — and only the plot does: an axis' numbers and the band the legend sits in are not part of it. withFocus={false} turns all of it off.

focusMode="nearest" focuses the single closest point instead of the column, for reading one series at a time rather than comparing series at one position.

The dot a line grows

The dot on a focused line is drawn with a ring outside it, in the line's own color held at a quarter opacity — a halo that makes the marker findable without making the reading look bigger than it is. The dot fades in and the ring grows out of the point, so the marker arrives rather than appearing.

Four per-mark options size it. The whole marker measures focusDotRadius plus focusDotRingWidth, so the defaults draw a 5px dot inside a 4px band:

  • focusDotRadius — the dot's radius in screen pixels. Defaults to 5.
  • focusDotRingWidth — the band outside it. Defaults to 4; 0 leaves the dot on its own.
  • focusDotRingColor — defaults to the line's own color. Set it to the surface the chart sits on to punch the dot out of whatever it lands on instead.
  • focusDotRingOpacity — defaults to 0.25, or to 1 where the ring was given a color of its own.
const marks = [
	line({
		data: byMonth,
		x: 'month',
		y: 'delivered',
		focusDotRadius: 5,
		focusDotRingWidth: 6,
	}),
];

Two per-mark options let a mark answer focus with color. They are independent and combine:

  • withHoverColor — while the mark is part of the focused group, it repaints in the hover twin of its color.
  • withUnfocusedDim — everything outside the focused group dims, leaving the focused column carrying the chart.
const marks = [
	bar({
		data: byMonth,
		x: 'month',
		y: 'delivered',
		series: 'channel',
		withHoverColor: true,
		withUnfocusedDim: true,
	}),
];

withHoverColor needs a color that has a hover twin — see Color.

Highlighting a series from the legend

withLegendHoverFocus lets a legend entry drive those same two options. Pointing at a name — or tabbing onto it, since every entry is a button — highlights that series across the whole plot: it swaps to its hover color where the mark asked for withHoverColor, and every other series recedes where the mark asked for withUnfocusedDim.

It adds no presentation of its own, so a mark with neither option set does not move. Turn at least one of them on.

No tooltip opens and no crosshair is drawn. An entry names a series rather than a place on the plot, so there is no value to read out; that stays the pointer's job on the plot itself.

Loading editor

Worth it once a chart carries enough series that finding one by color is work. On two or three it adds a response the reader did not need.

Zooming into a range

withZoom lets a reader drag a range out of the plot and read that range on its own. The chart redraws to what they selected: the horizontal axis covers it, the value axes refit to what is inside it, and a Reset zoom control appears in the top corner to put it back.

It is how a reader gets from ninety days of sending to what happened on the fourteenth, without the chart having to offer a date picker for a question they only have once.

Loading editor

Worth it on a chart of a long period, where the interesting stretch is a small part of the whole and the reader is the one who knows which stretch that is. Leave it off where the range is already the point — a chart of four quarters has nothing inside it to find — and on anything small enough that a drag would be a mis-click.

What a range means on each axis

A time or number axis is held to the range exactly: the plot's edges are where the reader dragged, not where the nearest bucket happens to sit. The rows either side of the window are still drawn and then clipped, so a line runs to the edge rather than stopping short of it — and because part of each of those segments is on the plot, the value axis covers them too. A window whose neighboring bucket is a spike keeps room for that spike.

A category axis takes the categories the drag crossed, since a category is either in or out — a categorical scale covers the categories it was given rather than a range between two of them. That is any chart whose horizontal values are names rather than dates or numbers: bars, and lines or areas over string categories alike.

Nothing zooms on a chart with no horizontal axis a range means something on: a radial mark, a sankey, and a horizontal bar chart, whose horizontal axis is its magnitude rather than its categories. withZoom on one of those does nothing rather than doing something strange.

The keyboard

The chart is already focusable, and with withZoom on it answers three more keys:

table
KeyWhat it does
+Halves the window, about its middle
-Widens it again, back to the whole of the data
0The whole of the data, in one press

Unmodified presses only. Ctrl or Cmd with any of them is the browser's own page zoom, and a reader shrinking the page should get a smaller page rather than a wider plot.

The arrow keys still walk the focused point, and Home still goes to the first one — a reader who has zoomed in wants the first point of what they are looking at, so the reset is 0 and nothing shares a key with the navigation. The Reset zoom control is a real button in the tab order too, so a reader who has landed on somebody else's zoomed link can always get out of it.

A chart that can be zoomed should say so in its ariaDescription, as the example above does. The scene is one image to a screen reader, so nothing about the plot announces that dragging it does anything.

Owning the window

The chart tracks what the reader drags. zoomWindow on its own is the range to open at, and the chart goes on tracking from there. Pass onZoomChange alongside it to own the window instead — which is what a chart whose range is also a date picker elsewhere on the page wants:

<Chart
	ariaLabel="Email opens per day"
	marks={marks}
	onZoomChange={setWindow}
	withZoom={true}
	zoomWindow={window}
/>

The values are the axis' own: dates on a time axis, numbers on a linear one, and the first and last category on a categorical one. null is the whole of the data.

Zoom and the tooltip

The tooltip works exactly as it did. The overlay a drag paints answers the pointer only while the drag is running, so pointing at the plot reads a column as it always has — and focus clears for the length of a drag, which is right: the reader is selecting a range rather than reading a column. Escape abandons a drag in progress.