Scatter Chart
Token-driven scatter and bubble chart built on Recharts v3 — points on two measured axes, a marker shape per series, optional size as a third measure, and a number, time or category x-axis.
pnpm add @tessinaui/uiWhen to use
- To show how two measures relate. Spend against seats, load time against page weight: each point is one thing, placed by two of its numbers.
- To find clusters, gaps and outliers. A scatter keeps every row visible, so the one account that does not fit the pattern stays on screen.
- To add a third measure as size. Set
sizeKeyand each point becomes a bubble (Bubbles). - For individual readings over time. Glucose readings, build times, sales: events that happened when they happened, with no line implied between them (Time axis).
- To sort things into four groups. Two reference lines turn the plot into quadrants (Quadrants).
When not to use
- For a trend over time. If the points are evenly spaced and the eye should follow them, use LineChart. A scatter draws no line because it claims none.
- To compare totals across categories. Use BarChart; length is easier to compare than position on two axes.
- For activity by day and hour. Use ActivityHeatmap.
- For more than four series. Markers and tones stop being tellable apart. Split the chart into small ones, or fold the smallest series into "Other".
- For more than a few hundred points. Points start to hide each other and the keyboard walk gets long. Group the rows into bins first, or show a sample.
- When the exact values matter more than the pattern. Put the numbers in a Table and keep the chart as the summary.
Installation
pnpm add @tessinaui/ui rechartsUsage
import { ScatterChart } from "@tessinaui/ui";
import type { ChartConfig } from "@tessinaui/ui";
const config: ChartConfig = {
starter: { label: "Starter", color: "var(--chart-1)" },
pro: { label: "Pro", color: "var(--chart-2)" },
};
// One row per point. `plan` names the series each row belongs to.
const data = [
{ seats: 5, spend: 140, plan: "starter" },
{ seats: 14, spend: 340, plan: "starter" },
{ seats: 18, spend: 710, plan: "pro" },
{ seats: 38, spend: 1350, plan: "pro" },
];
<ScatterChart
data={data}
config={config}
xKey="seats"
yKey="spend"
seriesKey="plan"
xAxisLabel="Seats"
yAxisLabel="Monthly spend"
label="Pro accounts spend more per seat than Starter accounts"
/>The data is one flat array. xKey and yKey name the two measures; seriesKey
names the field that says which series a row belongs to, and its values are the
keys of config. Leave seriesKey out and every row belongs to the first
series.
Examples
Default
One series on two measured axes. Hover a point, or Tab to the chart and use the arrow keys: the point takes a ring and the others step back.
Multiple series
Three plans in three tones of one colour. Each series also takes its own marker, so the chart reads the same in greyscale.
Markers
A series takes the next marker by its place in config: circle, square,
triangle, diamond, cross, star. shapes overrides one series, and
outlinedSeries draws a series as a ring instead of a fill.
Bubbles
sizeKey sizes each point by a third measure, and nameKey gives each point
its name in the tooltip.
Time axis
xScale="time" places points on a continuous time scale. Pass epoch
milliseconds, a Date or a date string.
Category axis
xScale="category" gives each distinct value a slot, in row order, so two
series can be compared category by category.
Axes
Titles, tick formatters, pinned ends and a right-hand value axis.
Point labels
showLabels prints each point's nameKey under its marker.
Quadrants
Two ReferenceLine children split the plot, and each quadrant is named in its
corner.
Target range
A ReferenceArea child draws the range, and readings outside it take a second
series with its own colour and marker. The range gets a legend entry of its
own.
Trend line
A fitted line is a ReferenceLine segment between two points you compute.
Sizes
Three aspect ratios: sm (short and wide), md, and lg (tall).
Series toggling
onSeriesToggle turns the legend items into toggle buttons. The hidden list is
yours to keep.
In a card
The heading, the insight and the range control belong to the card, not to the chart.
Accessible name and data table
label states the insight, description carries the numbers, and a table
holds the same rows for anyone who cannot read the plot.
Choosing a point
With selectionMode="single" a click, a tap, or Enter or Space on the point the
arrow keys are on chooses it, and choosing it again clears it. A point is chosen
by its nameKey value, so give every point its own name; without nameKey
each point is keyed by its place in its series and a development warning says
so. The chosen point wears a ring in the page's ink and the others step back to
a wash inside an outline; while a point is chosen, the one under the pointer
only steps forward, without a ring of its own. Here the card below names the
chosen account, and its close button clears it.
On a coloured plate
Inside a coloured Surface the chart's chrome takes the plate's ink with no
prop: tick text, axis titles, grid lines, the legend, printed labels and the
focus ring. Series colours are yours. The --chart-N tokens are measured
against the page, not a plate, so give each series the plate's ink
(hsl(var(--surface-ink))) and tell series apart by marker and by
outlinedSeries. The tooltip is a page panel, so it keeps the page's look.
Right-to-left
The chart reads the direction around it. In a right-to-left page the x-axis
runs from right to left and the value axis moves to the right-hand side; the
legend and the tooltip follow the text. Set dir on the chart to choose a
direction yourself, and yAxisPosition to keep the value axis on one side.
States
Loading skeleton, empty state, error, and a chart with a single reading.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
data | Record<string, string | number | Date | null>[] | — | One row per point. |
config | ChartConfig | — | Series metadata (label, color). |
xKey / yKey | string | — | Datum keys for the horizontal and vertical position. |
sizeKey | string | — | Datum key for the marker's size. Makes the chart a bubble chart. |
seriesKey | string | — | Datum key whose value names the row's series. Without it every row belongs to the first series. |
nameKey | string | — | Datum key naming the point: the tooltip heading, the printed label, and its key while selecting. |
series | string[] | all config keys | Which series to plot, in order. |
shapes | Record<string, "circle" | "square" | "triangle" | "diamond" | "cross" | "star"> | by config position | Marker per series. |
outlinedSeries | string[] | [] | Series drawn as an outline instead of a fill. |
sizeRange | [number, number] | [8, 40] | The bubble drawn for zero and for the largest value, in pixels across. |
sizeLabel | string | sizeKey | Names the size in the tooltip. |
sizeFormatter | (value: number) => string | — | Format the size in the tooltip. |
fillOpacity | number | 1, or 0.3 for bubbles | Marker fill opacity. |
xScale | "linear" | "time" | "category" | "linear" | How x positions are read. |
xDomain / yDomain | [number | "auto", number | "auto"] | fit the data | Pin either end of an axis. |
xAxisLabel / yAxisLabel | string | — | Axis titles. They also name the values in the tooltip. |
xTickFormatter / yTickFormatter | (value, index) => string | — | Format tick text and the tooltip's values. |
yAxisPosition | "left" | "right" | follows dir | Which side the y-axis sits on. |
size | "sm" | "md" | "lg" | "md" | Aspect ratio. |
showGrid / showTooltip / showXAxis / showYAxis | boolean | true | Toggle chart chrome. |
showLegend | boolean | more than one series | Show the legend. |
showLabels | boolean | false | Print each point's nameKey under its marker. |
hiddenSeries | string[] | — | Series currently hidden. |
onSeriesToggle | (key: string) => void | — | Makes the legend items toggle buttons. |
animate | boolean | true | Animate the points when they enter. Reduced motion turns it off regardless. |
selectionMode | "none" | "single" | "none" | "single" lets a reader choose one point by click, tap, Enter or Space; choosing it again clears it. |
selected | string | null | — | nameKey value of the chosen point, controlled. null chooses nothing. |
defaultSelected | string | null | null | nameKey value of the point chosen at first render, uncontrolled. |
onSelectedChange | (key, { datum, index, source }) => void | — | Called on every choice and clear; source is "pointer" or "keyboard". |
active | string | null | — | nameKey value of the lit point, controlled: opens the tooltip on it. |
onActiveChange | (key: string | null) => void | — | Called when the chart's own pointer or arrow keys move to another point. |
strings | Partial<ScatterChartStrings> | English, Arabic under dir="rtl" | Selection's words per entry: selected, cleared, items, point. |
children | ReactNode | — | Extra Recharts children (ReferenceLine, ReferenceArea, …). |
dir | "ltr" | "rtl" | inherited | Reading direction. Unset, read off the chart's position in the page once it mounts. |
label | string | — | Accessible name; renders the chart as a role="figure". |
description | string | — | Visually hidden summary for assistive tech. |
loading | boolean | false | Render the skeleton. |
error | ReactNode | — | Render an error message instead of the chart. |
emptyState | ReactNode | "No data to display" | Shown when no row can be plotted. |
Markers and colour
Colour is never the only thing that tells two series apart. Every series takes its own marker, and the legend draws that marker instead of a colour square, so the chart holds up in greyscale, in print and for a reader who cannot tell the hues apart.
- A marker belongs to its series, not to its rank. It is read from the series'
place in
config, so hiding or reordering series never reshuffles the rest. - Every marker is drawn at the same area. A triangle carries the same ink as a circle, so no series looks more important than another.
- Tones of one colour keep a chart calm, and markers do the telling apart. The
examples use three tones of
--chart-1:
const config: ChartConfig = {
starter: { label: "Starter", color: "oklch(from var(--chart-1) calc(l + 0.16) c h)" },
pro: { label: "Pro", color: "var(--chart-1)" },
team: { label: "Team", color: "oklch(from var(--chart-1) calc(l - 0.12) c h)" },
};- Use
outlinedSeriesfor a series that is a reference rather than a category: an estimate, a previous period, a reading that was not measured.
Bubble size
sizeKey scales each marker's area, starting from zero, so a value twice
as large is drawn with twice the ink. Scaling the radius instead would make it
look four times as large.
sizeRangeis the bubble drawn for zero and for the largest value, in pixels across.- A bubble is a see-through fill inside a full-strength outline, so overlapping bubbles stay readable through each other.
- A row with no size takes the smallest marker.
- There is no size legend. Name the measure with
sizeLabel; the tooltip states each point's value, anddescriptionshould state the range.
Missing and sparse data
A row that cannot be placed is left out. A row whose x or y is missing or not a number is dropped. It is never drawn at zero, because zero is a value.
A chart with a single reading still draws a real axis around it. When no row
can be placed at all, the chart shows emptyState.
Accessibility
- Give every chart a
label. It names the figure to assistive tech. State the insight, not the anatomy: "Spend per seat steps up with each plan", not "Scatter chart of spend by seats". - Put the numbers in
description. A static read should carry the message without anyone stepping through the points. - Offer the rows as a table when the exact values matter (Accessible name and data table).
- Keyboard. Tab moves focus to the chart, which draws the focus ring. The
Left and Right arrow keys step through every point of every series from the
start of the x-axis to its end, and each point is announced through the
tooltip: its name or series, then each value with its axis title. Escape
dismisses the tooltip without leaving the chart. With
onSeriesToggle, the legend items are buttons that come before the chart in the tab order. - Pointer. Each point has a target larger than its marker: 24px with a mouse, 44px under a finger.
- Motion. Points animate in once. Under
prefers-reduced-motionthey do not, and the dimming of the other points is instant. - Screen readers. Not yet verified with VoiceOver, NVDA or JAWS, which is why the component is in beta.
- With
selectionMode="single"the points become a list to choose from. The chart's one tab stop is a namedlistbox("Spend by seats: Points"), each point anoptionwitharia-selected, its place in the list, and a name that carries its readings, and the point the arrow keys are on is itsaria-activedescendant. Enter or Space chooses that point, so Enter no longer toggles the tooltip;Escapestill hides it. A polite status region says "Acme selected" or "Selection cleared" once per change. The screen reader pass of this mode is still to be run.
Composition
children are rendered inside the Recharts chart, so annotations stay Recharts'
own API instead of growing a prop each:
import { ReferenceArea, ReferenceLine } from "recharts";
<ScatterChart data={data} config={config} xKey="at" yKey="level" xScale="time">
<ReferenceArea y1={3.9} y2={10} fill="var(--chart-5)" fillOpacity={0.12} />
<ReferenceLine y={10} stroke="var(--chart-3)" strokeDasharray="6 4" />
</ScatterChart>A reference line left at its default ink is painted as a hairline to match the
grid; a stroke you pass is kept. A label is text, so give it a text ink
(hsl(var(--muted-foreground))), not a series colour: the --chart-N tokens
clear 3:1 for marks, not 4.5:1 for 12px type.
Boundaries
- No fitted line of its own. The chart draws the line it is handed (Trend line). The fit is your data's.
- No second y-axis. Two scales on one plot invite a comparison the numbers do not support. Draw two charts.
- No joined points. Points joined in order are a LineChart.
- No click or selection yet. A point shows its values on hover and focus; it is not a control.
Theming
Series colours come from config, as --chart-1 to --chart-6 or any colour
you pass; both themes are handled by the tokens. Axis text, grid lines, the
legend and the tooltip use the page's text, border and surface tokens, and
follow a coloured Surface without a prop.