Scatter chart
Two measures against each other, one mark per record, optionally sized by a third.
Any two marks in a scatter can end up side by side, so the palette has to hold across every pair rather than only neighbours — which it does for the first three slots. Past three series, facet into small multiples instead of adding colours. z sizes a mark by area, which is a rough third measure and not a precise one; it gets its own tooltip row under zLabel rather than a third scale, and belongs in the card's footer as a legend if the sizes carry meaning. Marks wear a ring in var(--chart-surface, var(--card)) so overlapping points stay countable — set --chart-surface when the chart sits on the page instead of in a card. Every point is also rendered as a visually hidden row.
Install
npx shadcn@latest add @vibra/scatter-chartNeeds the @vibra registry in your components.json — set it up once.
Examples
import { formatCurrency, formatNumber, formatPercent } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { ScatterChart } from "@/components/ui/scatter-chart"
const ACCOUNTS = [
{
name: "Enterprise",
data: [
{ x: 420, y: 0.96, z: 18400, label: "Northwind" },
{ x: 310, y: 0.94, z: 14200, label: "Wavelength Energy" },
{ x: 265, y: 0.88, z: 11800, label: "Granite Retail" },
{ x: 180, y: 0.91, z: 9400, label: "Harborview Robotics" },
{ x: 148, y: 0.79, z: 7200, label: "Silverpine Robotics" },
],
},
{
name: "Self-serve",
data: [
{ x: 24, y: 0.74, z: 1200, label: "Beacon Retail" },
{ x: 41, y: 0.81, z: 2100, label: "Alder Robotics" },
{ x: 12, y: 0.52, z: 640, label: "Wavelength Health" },
{ x: 63, y: 0.86, z: 3400, label: "Brightline Robotics" },
],
},
]
export default function ScatterChartDemo() {
return (
<ChartCard
title="Retention by account size"
description="Bubble size is monthly recurring revenue"
height={280}
className="w-full"
>
<ScatterChart
series={ACCOUNTS}
xLabel="Seats"
yLabel="Retention"
zLabel="MRR"
xFormatter={(value) => formatNumber(value, { maximumFractionDigits: 0 })}
yFormatter={(value) => formatPercent(value, { maximumFractionDigits: 0 })}
zFormatter={(value) => formatCurrency(value, "USD", { compact: true })}
height={280}
/>
</ChartCard>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| series | ScatterSeries[] | palette in order | Groups of points, as { name, color?, data }. Each point is { x, y, z?, label? }. |
| xLabel | string | "x" | What the horizontal axis measures; printed under it and read in the tooltip. |
| yLabel | string | "y" | What the vertical axis measures; printed beside it and read in the tooltip. |
| zLabel | string | "size" | What z measures. It reads in the tooltip and the text list, never on an axis — there is no third scale to print. |
| xFormatter | (n: number) => string | thousands-separated number | Formats the horizontal ticks, tooltip, and text rows. |
| yFormatter | (n: number) => string | thousands-separated number | Formats the vertical ticks, tooltip, and text rows. |
| zFormatter | (n: number) => string | thousands-separated number | Formats the bubble measure in the tooltip and the text rows. |
| height | number | 280 | Plot height in pixels. |
| showGrid | boolean | true | Hairline reference lines both ways — both axes are continuous here. |
| showLegend | boolean | true | Shows the key beneath the plot. With it off the key becomes the visually hidden list of series names. A single series never draws one. |
Dependencies
npm
Source
"use client"
import * as React from "react"
import { CartesianGrid, ScatterChart as RechartsScatterChart, Scatter, XAxis, YAxis, ZAxis } from "recharts"
import { cn } from "@/lib/utils"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import { AXIS_TICK, axisProps, chartDefaults, defaultValueFormatter, GRID_PROPS, seriesColor } from "@/components/ui/chart-core"
import type { ChartToken } from "@/components/ui/percentage-bar"
export type ScatterPoint = {
x: number
y: number
/** Sizes the mark, for a rough third measure. Points without one all draw the same size. */
z?: number
/** Names the point in its tooltip and in the text list, e.g. the account it belongs to. */
label?: string
}
export type ScatterSeries = {
name: string
/** A chart token, or any CSS colour. Defaults to the palette in order. */
color?: ChartToken | string
data: ScatterPoint[]
}
export type ScatterChartProps = React.ComponentProps<"div"> & {
series: ScatterSeries[]
xLabel?: string
yLabel?: string
/** What `z` measures. It reads in the tooltip and the text list, never on an axis. */
zLabel?: string
xFormatter?: (n: number) => string
yFormatter?: (n: number) => string
zFormatter?: (n: number) => string
height?: number
showGrid?: boolean
showLegend?: boolean
}
// The z range in pixels² — recharts sizes each mark's area between these, so a
// bubble ten times the value is about three times as wide.
const Z_RANGE: [number, number] = [64, 400]
function ScatterChart({
className,
series,
xLabel,
yLabel,
zLabel,
xFormatter = defaultValueFormatter,
yFormatter = defaultValueFormatter,
zFormatter = defaultValueFormatter,
height = chartDefaults.height,
showGrid = chartDefaults.showGrid,
showLegend = chartDefaults.showLegend,
...props
}: ScatterChartProps) {
// A Map, not an object: series names are caller data, and a name like
// "toString" must not resolve to something off Object's prototype.
const colors = React.useMemo(
() =>
new Map(
series.map((entry, i) => [
entry.name,
seriesColor({ key: entry.name, label: entry.name, color: entry.color }, i),
])
),
[series]
)
// Labels only — the marks are painted per series, so there is no
// --color-<key> to emit and a name with a space in it never becomes an
// invalid custom property.
const config = React.useMemo<ChartConfig>(
() => Object.fromEntries(series.map((entry) => [entry.name, { label: entry.name }])),
[series]
)
const xTitle = xLabel ?? "x"
const yTitle = yLabel ?? "y"
const zTitle = zLabel ?? "size"
// Recharts puts one row per axis in a scatter's tooltip payload, z included
// whenever a ZAxis is mounted, so each row is matched to its own axis rather
// than assumed to be y — otherwise the bubble measure prints under the y
// title, through the y formatter, as a number nobody recognises.
const axisOf = (dataKey: unknown) => {
if (dataKey === "x") return { title: xTitle, format: xFormatter }
if (dataKey === "z") return { title: zTitle, format: zFormatter }
return { title: yTitle, format: yFormatter }
}
const points = series.reduce((count, entry) => count + entry.data.length, 0)
const sized = series.some((entry) => entry.data.some((point) => point.z !== undefined))
const named = series.some((entry) => entry.data.some((point) => point.label !== undefined))
const summary =
points === 0
? "Scatter chart with no points."
: `Scatter chart of ${series.length === 1 ? series[0].name : `${series.length} series`}, ${points} points, ${yTitle} against ${xTitle}.`
return (
<div data-slot="scatter-chart" className={cn("flex w-full flex-col", className)} {...props}>
{/* The axis titles are text on the page rather than SVG inside the plot:
they wear the theme's own type and colour tokens, they stay selectable,
and the value axis can size itself to its ticks without a rotated
label to leave room for. */}
<div className="flex items-stretch">
{yLabel ? (
<div
data-slot="scatter-chart-y-label"
className="flex items-center justify-center pe-1 text-xs text-muted-foreground [writing-mode:vertical-rl] rotate-180"
style={{ height }}
>
{yLabel}
</div>
) : null}
<ChartContainer
config={config}
role="img"
aria-label={summary}
className="aspect-auto w-full min-w-0"
style={{ height }}
>
<RechartsScatterChart
// Named once, by the container's role="img" and its summary; recharts'
// keyboard layer would add an unnamed role="application" tab stop inside it.
accessibilityLayer={false}
margin={{ top: 8, right: 12, bottom: 0, left: 4 }}>
{showGrid ? <CartesianGrid {...GRID_PROPS} /> : null}
<XAxis
tick={AXIS_TICK}
type="number"
dataKey="x"
name={xTitle}
{...axisProps}
tickFormatter={(value) => xFormatter(Number(value))}
/>
<YAxis
tick={AXIS_TICK}
type="number"
dataKey="y"
name={yTitle}
{...axisProps}
width="auto"
tickFormatter={(value) => yFormatter(Number(value))}
/>
{sized ? <ZAxis type="number" dataKey="z" range={Z_RANGE} /> : null}
<ChartTooltip
cursor={{ strokeDasharray: "4 4" }}
content={
<ChartTooltipContent
hideLabel={!named}
labelFormatter={(_, payload) => {
const point = payload?.[0]?.payload as ScatterPoint | undefined
return point?.label ?? ""
}}
// The primitive prints raw numbers, so each axis row is rebuilt
// here to go through that axis' own formatter.
formatter={(value, name, item) => {
const axis = axisOf(item.dataKey)
return (
<div className="flex flex-1 items-center justify-between gap-3 leading-none">
<span className="text-muted-foreground">{axis.title}</span>
<span className="font-medium tabular-nums">
{axis.format(Number(value))}
</span>
</div>
)
}}
/>
}
/>
{series.map((entry) => (
<Scatter
key={entry.name}
name={entry.name}
data={entry.data}
fill={colors.get(entry.name)}
fillOpacity={0.75}
// The ring is the surface showing through, so overlapping marks
// stay countable instead of merging into a blob.
stroke="var(--chart-surface, var(--card))"
strokeWidth={1}
// No draw-in (CONVENTIONS §6), in any mode: recharts' own default
// answers only the OS setting, so under [data-motion="reduced"]
// the marks still flew in.
isAnimationActive={false}
/>
))}
</RechartsScatterChart>
</ChartContainer>
</div>
{xLabel ? (
<div
data-slot="scatter-chart-x-label"
className="pt-1 text-center text-xs text-muted-foreground"
>
{xLabel}
</div>
) : null}
{series.length > 1 ? (
<ul
data-slot="scatter-chart-legend"
className={cn(
showLegend ? "flex flex-wrap justify-center gap-x-4 gap-y-1.5 pt-3 text-xs" : "sr-only"
)}
>
{series.map((entry) => (
<li key={entry.name} className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: colors.get(entry.name) }}
/>
<span className="text-muted-foreground">{entry.name}</span>
</li>
))}
</ul>
) : null}
<ul data-slot="scatter-chart-data" aria-label={summary} className="sr-only">
{series.flatMap((entry) =>
entry.data.map((point, i) => (
<li key={`${entry.name}-${i}`}>
{`${entry.name}${point.label ? `, ${point.label}` : ""}: ${xTitle} ${xFormatter(point.x)}, ${yTitle} ${yFormatter(point.y)}${point.z === undefined ? "" : `, ${zTitle} ${zFormatter(point.z)}`}`}
</li>
))
)}
</ul>
</div>
)
}
export { ScatterChart }import * as React from "react"
import { formatNumber } from "@/lib/format"
import type { ChartConfig } from "@/components/ui/chart"
import { isChartToken, type ChartToken } from "@/components/ui/percentage-bar"
/** One point of a chart: the index value plus one number per series key. */
export type ChartDatum = Record<string, string | number | null | undefined>
/** One plotted measure — which key to read, what to call it, what to paint it. */
export type ChartSeries = {
key: string
label: string
/** A chart token, or any CSS colour for a brand hue. Defaults to the palette in order. */
color?: ChartToken | string
/**
* The key holding the same measure over the period before this one. Drawn as
* a dashed ghost behind the series and printed in its tooltip row as a delta.
*/
compareKey?: string
/**
* The colour the ghost is drawn in: a chart token or any CSS colour. Left
* out, the ghost wears the series' own colour; `var(--chart-neutral)` keeps
* the period before out of the palette, grey under every preset — including
* one whose first slot follows a coloured accent.
*/
compareColor?: ChartToken | string
}
/** The props every cartesian chart in the set accepts. */
export type CommonChartProps = React.ComponentProps<"div"> & {
data: ChartDatum[]
/** The key holding each point's category — the month, the day, the source. */
index: string
series: ChartSeries[]
/** Formats every number the chart prints: axis ticks, tooltips, and the text summary. */
valueFormatter?: (n: number) => string
/** Formats every index label, e.g. an ISO date into "Sep 4". */
indexFormatter?: (v: string | number) => string
/** Plot height in pixels, axis band included. */
height?: number
showLegend?: boolean
showGrid?: boolean
showXAxis?: boolean
showYAxis?: boolean
showTooltip?: boolean
/** Where the series are named: beside their last point, under the plot, or nowhere. */
legend?: ChartLegendPlacement
/** Goal lines, event markers and bands drawn over the plot and read out as text. */
annotations?: ChartAnnotation[]
/** Draws each series' `compareKey` as a dashed ghost behind it, in the series' `compareColor` if it names one, its own colour if not. */
compare?: boolean
className?: string
}
// The palette wraps rather than inventing a ninth hue: past eight series the
// colours stop being distinguishable, so fold the tail into an "Other" series.
const PALETTE_SIZE = 8
/**
* A series' colour: a chart token becomes its CSS variable, any other string is
* taken as a raw CSS colour, and a series that names none takes the next slot in
* the palette, wrapping past the eighth.
*/
export function seriesColor(series: ChartSeries, i: number): string {
const color = series.color
if (color === undefined) return `var(--chart-${(i % PALETTE_SIZE) + 1})`
return isChartToken(color) ? `var(--${color})` : color
}
/**
* The ChartConfig the chart primitive needs — it turns each entry into a
* `--color-<key>` custom property that recharts marks reference.
*/
export function buildChartConfig(series: ChartSeries[]): ChartConfig {
// fromEntries defines own properties, so a series keyed "__proto__" lands as
// data instead of reassigning the config object's prototype.
return Object.fromEntries(
series.map((entry, i) => [entry.key, { label: entry.label, color: seriesColor(entry, i) }])
)
}
/**
* The shared starting point: a 280px plot with both axes, a grid, direct labels
* at the end of each series, and a tooltip. The value axis is on — a chart
* without a printed scale is a shape, not a measurement — and the charts turn it
* off themselves when the plot is too small to carry one (`fitsYAxis`).
*/
export const chartDefaults = {
height: 280,
showLegend: true,
showGrid: true,
showXAxis: true,
showYAxis: true,
showTooltip: true,
legend: "inline-end",
} as const
/**
* A drawn line: 2px with round caps and joins reads as a stroke at any size
* and holds its own on a white sheet, where 1.5px thinned to a hair on a
* high-density screen.
*/
export const STROKE_WIDTH = 2
/**
* The gridline: the grid token, dashed, so a rule under the data reads as a
* guide rather than as a border. Every cartesian chart spreads this onto its
* CartesianGrid.
*/
export const GRID_PROPS = { stroke: "var(--chart-grid)", strokeDasharray: "3 3" } as const
/**
* The dot under the pointer: 3.5px of the series' own colour, ringed in the
* surface it sits on so it stays legible where two lines cross.
* --chart-surface falls back to the card a chart normally lives on; set it on
* any ancestor when the chart sits on another plane.
*/
export const ACTIVE_DOT = { r: 3.5, strokeWidth: 2, stroke: "var(--chart-surface, var(--card))" } as const
/**
* The corner a column turns, and the gap between two segments of one stack. A
* stack is rounded as one shape — the top corners on the topmost segment, the
* bottom corners on the lowest — so a bar reads as a printed block rather than
* a pile of separately rounded tiles.
*/
export const BAR_RADIUS = 6
export const BAR_GAP = 1
/** Axis chrome: no rule and no tick marks, so only the labels carry the scale. */
export const axisProps = { tickLine: false, axisLine: false, tickMargin: 8, fontSize: 12 } as const
/**
* The paint every axis label takes: --chart-axis, the token §3.8 names for the
* printed scale. recharts writes fill="#666" onto its own
* tick text, and the primitive's `.recharts-cartesian-axis-tick text` rule does
* not reach it — recharts 3 nests labels under
* `.recharts-cartesian-axis-tick-labels` instead — so #666 survived on the card
* at 3.16:1 in dark. Passing the fill as a tick prop puts it on the element
* itself, where nothing has to match a selector.
*/
export const AXIS_TICK = { fill: "var(--chart-axis)" } as const
/** Chart numbers fall back to thousands-separated values when no valueFormatter is given. */
export function defaultValueFormatter(value: number): string {
return formatNumber(value)
}
/** Index labels print as they arrive unless the chart is given an indexFormatter. */
export function defaultIndexFormatter(value: string | number): string {
return String(value)
}
/** What the text helpers need to turn a chart's data back into words. */
export type ChartTextOptions = {
data: ChartDatum[]
index: string
series: ChartSeries[]
valueFormatter?: (n: number) => string
indexFormatter?: (v: string | number) => string
}
function readIndex(datum: ChartDatum, index: string, format: (v: string | number) => string) {
const raw = datum[index]
return typeof raw === "string" || typeof raw === "number" ? format(raw) : ""
}
function readValue(raw: ChartDatum[string], format: (n: number) => string) {
return typeof raw === "number" && Number.isFinite(raw) ? format(raw) : "no data"
}
function joinLabels(labels: string[]): string {
if (labels.length < 2) return labels.join("")
return `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}`
}
/**
* The plotted data as text, one row per point. Every chart renders this into a
* visually hidden list, so the numbers are readable without pointing at a tooltip.
*/
export function chartRows(options: ChartTextOptions): { label: string; readings: string }[] {
const {
data,
index,
series,
valueFormatter = defaultValueFormatter,
indexFormatter = defaultIndexFormatter,
} = options
return data.map((datum) => ({
label: readIndex(datum, index, indexFormatter),
readings: series
.map((entry) => `${entry.label} ${readValue(datum[entry.key], valueFormatter)}`)
.join(", "),
}))
}
/**
* The heading for a tooltip: the point's own index value, read straight off the
* datum. The primitive resolves its label through the config, which only works
* when the index is a string — reading the datum keeps numeric indexes intact.
*/
export function tooltipIndexLabel(
payload: ReadonlyArray<{ payload?: unknown }> | undefined,
index: string,
format: (v: string | number) => string = defaultIndexFormatter
): string {
const datum = payload?.[0]?.payload
const raw = datum && typeof datum === "object" ? (datum as ChartDatum)[index] : undefined
return typeof raw === "string" || typeof raw === "number" ? format(raw) : ""
}
/** A one-sentence description of what a chart plots, used as its accessible name. */
export function chartSummary(kind: string, options: ChartTextOptions): string {
const { data, index, series, indexFormatter = defaultIndexFormatter } = options
if (series.length === 0) return `${kind} with no series.`
const labels = joinLabels(series.map((entry) => entry.label))
if (data.length === 0) return `${kind} of ${labels} by ${index}. No data.`
const first = readIndex(data[0], index, indexFormatter)
const last = readIndex(data[data.length - 1], index, indexFormatter)
const count = `${data.length} point${data.length === 1 ? "" : "s"}`
return `${kind} of ${labels} by ${index}, ${count} from ${first} to ${last}.`
}
/* -------------------------------------------------------------------------- */
/* Annotations */
/* -------------------------------------------------------------------------- */
/** Where a chart names its series. */
export type ChartLegendPlacement = "inline-end" | "bottom" | "none"
/** The meanings an annotation can carry; each resolves to a token, never a literal. */
export type ChartAnnotationTone = "neutral" | "brand" | "positive" | "negative" | "warning" | "info"
export type ChartAnnotation =
/** A threshold across the plot: a goal, a limit, an included allowance. */
| { kind: "line"; axis?: "x" | "y"; value: number | string; label: string; tone?: ChartAnnotationTone }
/** A moment on the index axis: a launch, a deploy, an incident. */
| { kind: "event"; x: number | string; label: string; tone?: ChartAnnotationTone; href?: string }
/**
* A stretch of the index axis: a freeze, an outage, a campaign. The label
* reads from the band's start; `align: "end"` hangs it from the band's end
* instead, for a band that runs to the edge of the plot — a 59px word over
* a 30px band at the right edge otherwise runs out of the plot.
*/
| {
kind: "band"
from: number | string
to: number | string
label: string
tone?: ChartAnnotationTone
align?: "start" | "end"
}
const ANNOTATION_PAINT: Record<ChartAnnotationTone, string> = {
neutral: "var(--faint-foreground)",
brand: "var(--brand)",
positive: "var(--chart-positive)",
negative: "var(--chart-negative)",
warning: "var(--warning)",
info: "var(--info)",
}
/**
* The paint a *meaning* takes, as opposed to a category.
*
* A series that is coded by status — 200/429/500, up/down, paid/overdue — is
* not one of eight interchangeable hues: it has to resolve to the semantic
* tokens, or a reader learns the wrong colour for "failed" on one page and
* carries it to the next. Categories keep the palette; meanings come from here.
*/
export const CHART_TONES = {
positive: "var(--chart-positive)",
negative: "var(--chart-negative)",
neutral: "var(--chart-neutral)",
warning: "var(--warning)",
info: "var(--info)",
} as const
export type ChartTone = keyof typeof CHART_TONES
export function chartTone(tone: ChartTone): string {
return CHART_TONES[tone]
}
/** The token an annotation's tone paints in. */
export function annotationPaint(tone: ChartAnnotationTone = "neutral"): string {
return ANNOTATION_PAINT[tone] ?? ANNOTATION_PAINT.neutral
}
/** The numbers an annotation pins to the value axis, so the scale can hold them. */
export function annotationValues(annotation: ChartAnnotation): number[] {
if (annotation.kind === "line" && annotation.axis !== "x" && typeof annotation.value === "number")
return [annotation.value]
return []
}
/**
* Every annotation as a line of text, appended to a chart's visually hidden
* rows: a goal line a sighted reader can see has to be readable too.
*/
export function annotationRows(
annotations: ChartAnnotation[] = [],
options: {
valueFormatter?: (n: number) => string
indexFormatter?: (v: string | number) => string
} = {}
): string[] {
const {
valueFormatter = defaultValueFormatter,
indexFormatter = defaultIndexFormatter,
} = options
const at = (value: number | string) =>
typeof value === "number" ? valueFormatter(value) : indexFormatter(value)
return annotations.map((annotation) => {
if (annotation.kind === "line")
return `${annotation.label}: ${
annotation.axis === "x" ? indexFormatter(annotation.value) : at(annotation.value)
}`
if (annotation.kind === "event") return `${annotation.label} at ${indexFormatter(annotation.x)}`
return `${annotation.label}: ${indexFormatter(annotation.from)} to ${indexFormatter(annotation.to)}`
})
}