const accounts = /* ... */;
<ScatterChart
data={accounts}
x="seats"
y="spend"
series="plan"
seriesOrder={['starter', 'growth', 'scale']}
labels={{ starter: 'Starter', growth: 'Growth', scale: 'Scale' }}
rowKey="id"
ariaLabel="Monthly spend by seat count, grouped by plan"
formatY={{
locale: 'en-US',
number: {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
},
}}
className="w-full"
/>A scatter plot answers one question: do two measures move together? Position carries the evidence — one dot per row, a linear scale on both axes. Color and radius each add one more dimension when you need it.
Installation
npx shadcn@latest add @dotui/chart-scatterIt brings the chart core — the host, the palette, and the shared frame — along with it.
Usage
import { ScatterChart } from '@/ui/chart-scatter'Give it your rows and the two numeric fields to position them by. ariaLabel is required — a chart is a figure, not decoration.
const routes = [
{ route: '/', size: 118, load: 0.8 },
{ route: '/docs', size: 212, load: 1.4 },
{ route: '/editor', size: 476, load: 3.2 },
]
export function Example() {
return (
<ScatterChart
data={routes}
x="size"
y="load"
rowKey="route"
ariaLabel="First load time by bundle size"
/>
)
}x and y accept only numeric fields of your row type, so a typo or a string field is a type error rather than an empty chart. rowKey names the field that identifies a row, which keeps a dot animating in place when the data is filtered or re-sorted instead of respawning it.
Bubbles
Pass r to size each dot by a third measure. Values map onto radiusRange through a square-root scale, so area reads as magnitude — a country with twice the population draws twice the ink, not four times.
<ScatterChart
data={countries}
x="income"
y="lifespan"
r="population"
radiusRange={[4, 26]}
fillOpacity={0.55}
ariaLabel="Life expectancy by income, sized by population"
/>Bubbles overlap by nature; fillOpacity keeps the ones underneath readable. Without r, every dot uses the flat radius.
Groups
series splits rows into colored groups and turns on the legend. seriesOrder fixes color-slot assignment so a group keeps its color when the data changes, and labels maps raw keys to display names.
<ScatterChart
data={accounts}
x="seats"
y="spend"
series="plan"
seriesOrder={['starter', 'growth', 'scale']}
labels={{ starter: 'Starter', growth: 'Growth', scale: 'Scale' }}
ariaLabel="Monthly spend by seat count, grouped by plan"
/>The legend follows series: an ungrouped scatter has nothing to name, so it renders none. Pass legend explicitly to override either way.
Overlays
A trend line is derived data. Fit it in your application, then pass the result as its own mark layer — marksBefore paints under the dots, marks over them. The chart draws the model; it does not fit it.
import { lineY } from '@tanstack/charts/line'
// Computed once, outside render: mark layers are compared by identity.
const trend = [
lineY(fit, {
x: 'spend',
y: 'signups',
stroke: 'var(--color-fg-muted)',
strokeDasharray: '4 4',
}),
]
<ScatterChart
data={campaigns}
x="spend"
y="signups"
marksBefore={trend}
ariaLabel="Signups by campaign spend"
/>The same escape hatch takes a ruleY for a target line, or a text layer for annotations. Any mark from @tanstack/charts shares the chart's scales.
Dense data
Scatter focuses the nearest point in two dimensions, unlike the category-axis charts that highlight a whole column. In a dense cloud, lower fillOpacity to expose the distribution and tighten maxFocusDistance so a hover never claims a point on the far side of the plot.
<ScatterChart
data={requests}
x="latency"
y="errors"
radius={2.5}
fillOpacity={0.35}
maxFocusDistance={12}
ariaLabel="Error rate by response time"
/>Above roughly 800 points, animation turns itself off — the redraw cost stops being worth it.
Accessibility
The chart surface is focusable and keyboard-navigable: arrow keys move between points, and each focused point is announced with its axis values. ariaLabel is required and names the figure; ariaDescription adds the longer explanation a sighted reader gets from the surrounding copy.
Color is never the only encoding — position always carries the relationship, and groups stay distinguishable through the legend and the tooltip. When a group's name matters more than its hue, say so in labels rather than relying on the palette.
Examples
Default
Bubble
Grouped
With Regression
Dense
API Reference
ScatterChart
A scatter plot: one dot per row, positioned by two quantitative fields, with optional color grouping and an optional radius channel for bubbles. It also accepts every prop of `Chart` (`ariaLabel`, `height`, `className`, …) and every interaction prop of `ChartBehaviorProps`.
| Prop | Type | Default | |
|---|---|---|---|
readonly unknown[] | — | ||
string | — | ||
string | — | ||
string | — | ||
number | 4 | ||
readonly [number, number] | [3, 18] | ||
string | — | ||
readonly string[] | — | ||
Readonly<Record<string, string>> | — | ||
number | — | ||
string | — | ||
boolean | true | ||
boolean | true | ||
boolean | — | ||
ChartFormat | — | ||
ChartFormat | — | ||
readonly ChartMarkLayer[] | — | ||
readonly ChartMarkLayer[] | — | ||
string | — |
Interaction and animation props are shared by every chart family:
Interaction and animation props shared by every chart family component. They are flat scalars on purpose: the chart definition is memoized on a serialized key, and a nested option object would silently go stale.
| Prop | Type | Default | |
|---|---|---|---|
ChartFocus | "group-x" | ||
number | — | ||
ChartTooltipAnchor | "group-center" | ||
boolean | true | ||
false | — | ||
ChartAnimate | { duration: 240, respectReducedMotion: true } |
The host props — height, width, className, and the focus callbacks — are documented on the Chart page.
Last updated on 8/1/2026