A scatter or bubble chart with grouped colors, point sizes from a third number and titled axes, in a frame with a toggling legend, a table of every point with CSV download, and states.
Price and rating
Ten headphones
"use client"
import { ChartScatter } from "@/components/ballmac/chart-scatter"
const data = [
{ name: "Aero", price: 29, rating: 3.9 },
{ name: "Brio", price: 39, rating: 4.1 },
{ name: "Cove", price: 45, rating: 4.4 },
{ name: "Dune", price: 52, rating: 4.2 },
{ name: "Echo", price: 58, rating: 4.6 },
{ name: "Flux", price: 64, rating: 4.5 },
{ name: "Glow", price: 71, rating: 4.8 },
{ name: "Hive", price: 79, rating: 4.7 },
{ name: "Iris", price: 88, rating: 4.9 },
{ name: "Jolt", price: 95, rating: 4.3 },
]
export default function ChartScatterDemo() {
return (
<ChartScatter
className="w-full max-w-2xl"
title="Price and rating"
description="Ten headphones"
data={data}
xKey="price"
yKey="rating"
nameKey="name"
xLabel="Price"
yLabel="Rating"
xFormatter={(v) => `$${v}`}
yDomain={[3.5, 5]}
categoryLabel="Model"
/>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/chart-scatterInstall the dependencies.
$ pnpm add recharts@^3Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/chart @ballmac/chart-frame @ballmac/i18nCopy the source into your project.
components/ballmac/chart-scatter.tsx// Ballmac UI: Chart Scatter. https://ui.ballmac.com/components/chart-scatter "use client" import * as React from "react" import { CartesianGrid, Scatter, ScatterChart, XAxis, YAxis, ZAxis } from "recharts" import { ChartTooltip } from "@/components/ballmac/chart" import { ChartFrame, domainTicks, niceScale, type ChartFrameProps, type ChartRow, type ChartSeries } from "@/components/ballmac/chart-frame" import { useLocale, useMessages } from "@/lib/ballmac/i18n" type ChartScatterProps = Omit<ChartFrameProps, "children" | "series" | "xKey" | "data" | "summaryMode" | "skeleton" | "table"> & { /** One row per point: a number under `xKey` and one under `yKey`, optionally a size, a group and a name. */ data: ChartRow[] xKey: string yKey: string /** The key of a number that sets each point's size, which turns the scatter into a bubble chart. */ zKey?: string /** The key that says which group a point belongs to. Each group gets a color and a legend item. */ groupKey?: string /** Names and colors for the groups. Without it every point is one group. */ groups?: ChartSeries[] /** The key of each point's name, shown in the tooltip and the table. */ nameKey?: string /** Axis titles, also used in the tooltip and the table. */ xLabel: string yLabel: string zLabel?: string xFormatter?: (value: number) => string yFormatter?: (value: number) => string xDomain?: [number | "auto", number | "auto"] yDomain?: [number | "auto", number | "auto"] /** Smallest and largest point size in pixels, when `zKey` is set. */ sizeRange?: [number, number] grid?: "both" | "horizontal" | "none" tooltip?: boolean } /** * A scatter or bubble chart in a ChartFrame: grouped points with colors, sizes from a third number, titled axes, a legend that switches groups off, * a table of every point with a CSV download, a written summary of the ranges, and loading, empty and error states. */ function ChartScatter({ data, xKey, yKey, zKey, groupKey, groups, nameKey, xLabel, yLabel, zLabel, xFormatter, yFormatter, xDomain, yDomain, sizeRange = [80, 520], grid = "both", tooltip = true, categoryLabel, summary, aspect = "video", ...frame }: ChartScatterProps) { const msg = useMessages() const locale = useLocale() const nf = React.useMemo(() => new Intl.NumberFormat(locale), [locale]) const items: ChartSeries[] = groups ?? [{ key: "all", label: yLabel }] const number = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : null) const rangeOf = (key: string) => { const values = data.map((r) => number(r[key])).filter((v): v is number => v !== null) return values.length ? { min: Math.min(...values), max: Math.max(...values) } : null } const xr = rangeOf(xKey) const yr = rangeOf(yKey) const written = summary ?? (data.length && xr && yr ? msg("chart-scatter.summary", { one: "{count} point. {x} from {xMin} to {xMax}; {y} from {yMin} to {yMax}.", other: "{count} points. {x} from {xMin} to {xMax}; {y} from {yMin} to {yMax}." }, { count: data.length, x: xLabel, xMin: nf.format(xr.min), xMax: nf.format(xr.max), y: yLabel, yMin: nf.format(yr.min), yMax: nf.format(yr.max) }) : "") const writeX = (v: unknown) => (number(v) === null ? "–" : xFormatter ? xFormatter(v as number) : nf.format(v as number)) const writeY = (v: unknown) => (number(v) === null ? "–" : yFormatter ? yFormatter(v as number) : nf.format(v as number)) return ( <ChartFrame data={data} xKey={nameKey ?? xKey} series={items} summary={written} aspect={aspect} skeleton="area" legend={groups ? "toggle" : "none"} categoryLabel={categoryLabel} table={{ columns: [ ...(nameKey ? [{ key: nameKey, label: categoryLabel ?? msg("chart-scatter.name", "Name") }] : []), ...(groupKey && groups ? [{ key: groupKey, label: msg("chart-scatter.group", "Group"), format: (v: unknown) => groups.find((g) => g.key === String(v))?.label ?? String(v) }] : []), { key: xKey, label: xLabel, numeric: true, format: writeX }, { key: yKey, label: yLabel, numeric: true, format: writeY }, ...(zKey ? [{ key: zKey, label: zLabel ?? zKey, numeric: true, format: (v: unknown) => (number(v) === null ? "–" : nf.format(v as number)) }] : []), ], rows: data, }} {...frame} > {(chart) => { const visibleKeys = groups ? chart.visible : ["all"] const rows = groupKey && groups ? data.filter((r) => visibleKeys.includes(String(r[groupKey]))) : data const xs = xDomain ? undefined : niceScale({ data: rows, keys: [xKey], zero: false }) const ys = yDomain ? undefined : niceScale({ data: rows, keys: [yKey], zero: false }) return ( <ScatterChart margin={chart.margin({ top: 8, right: 16, bottom: 18, left: 8 })} accessibilityLayer> <defs>{chart.defs}</defs> <CartesianGrid vertical={grid === "both"} horizontal={grid !== "none"} /> <XAxis type="number" dataKey={xKey} name={xLabel} reversed={chart.rtl} tickLine={false} axisLine={false} tickMargin={8} domain={xDomain ?? xs?.domain} ticks={xs?.ticks ?? domainTicks(xDomain)} tickFormatter={(v: number) => (xFormatter ? xFormatter(v) : nf.format(v))} label={{ value: xLabel, position: "insideBottom", offset: -14, fill: "var(--muted-foreground)", fontSize: 12 }} /> <YAxis type="number" dataKey={yKey} name={yLabel} width="auto" orientation={chart.rtl ? "right" : "left"} tickLine={false} axisLine={false} tickMargin={8} domain={yDomain ?? ys?.domain} ticks={ys?.ticks ?? domainTicks(yDomain)} tickFormatter={(v: number) => (yFormatter ? yFormatter(v) : nf.format(v))} label={{ value: yLabel, angle: chart.rtl ? 90 : -90, position: chart.rtl ? "insideRight" : "insideLeft", offset: 4, style: { textAnchor: "middle" }, fill: "var(--muted-foreground)", fontSize: 12 }} /> {zKey ? <ZAxis type="number" dataKey={zKey} name={zLabel ?? zKey} range={sizeRange} /> : <ZAxis range={[90, 90]} />} {tooltip ? ( <ChartTooltip cursor={{ strokeDasharray: "3 3" }} content={({ active, payload }) => { const row = active ? (payload?.[0]?.payload as ChartRow | undefined) : undefined if (!row) return null const group = groupKey && groups ? groups.find((g) => g.key === String(row[groupKey])) : undefined return ( <div className="grid min-w-36 gap-1.5 rounded-lg border bg-popover px-2.5 py-1.5 text-xs text-popover-foreground shadow-[0_12px_36px_-10px_rgb(0_0_0/0.25)]"> {nameKey || group ? <p className="font-medium">{nameKey ? String(row[nameKey]) : group?.label}</p> : null} {nameKey && group ? <p className="text-muted-foreground">{group.label}</p> : null} <dl className="grid gap-1"> {[ [xLabel, writeX(row[xKey])], [yLabel, writeY(row[yKey])], ...(zKey ? [[zLabel ?? zKey, number(row[zKey]) === null ? "–" : nf.format(row[zKey] as number)]] : []), ].map(([k, v]) => ( <div key={k} className="flex items-center justify-between gap-4"> <dt className="text-muted-foreground">{k}</dt> <dd className="font-mono font-medium tabular-nums">{v}</dd> </div> ))} </dl> </div> ) }} /> ) : null} {items.map((g) => ( <Scatter key={g.key} name={g.label} data={groupKey && groups ? data.filter((r) => String(r[groupKey]) === g.key) : data} hide={chart.isHidden(g.key)} fill={chart.patterns ? chart.fill(g.key) : chart.color(g.key)} fillOpacity={zKey ? 0.7 : 0.9} stroke={chart.color(g.key)} strokeWidth={chart.patterns || zKey ? 1.5 : 0} isAnimationActive={chart.animate} animationDuration={600} /> ))} </ScatterChart> ) }} </ChartFrame> ) } export { ChartScatter, type ChartScatterProps }Update the import paths to match your project setup.
Usage
import { ChartScatter } from "@/components/ballmac/chart-scatter"The full example is in the Code tab above.
Examples
Groups by color
Usage by seats
One point per account, colored by plan
"use client"
import { ChartScatter } from "@/components/ballmac/chart-scatter"
const data = [
{ plan: "free", seats: 2, usage: 140 }, { plan: "free", seats: 3, usage: 210 }, { plan: "free", seats: 1, usage: 90 }, { plan: "free", seats: 4, usage: 260 }, { plan: "free", seats: 2, usage: 180 },
{ plan: "pro", seats: 6, usage: 820 }, { plan: "pro", seats: 9, usage: 1180 }, { plan: "pro", seats: 12, usage: 1420 }, { plan: "pro", seats: 8, usage: 910 }, { plan: "pro", seats: 14, usage: 1710 },
{ plan: "team", seats: 24, usage: 3200 }, { plan: "team", seats: 31, usage: 3900 }, { plan: "team", seats: 42, usage: 5100 }, { plan: "team", seats: 28, usage: 3600 }, { plan: "team", seats: 36, usage: 4300 },
]
export default function ChartScatterGroups() {
return (
<ChartScatter
className="w-full max-w-2xl"
title="Usage by seats"
description="One point per account, colored by plan"
data={data}
xKey="seats"
yKey="usage"
groupKey="plan"
xLabel="Seats"
yLabel="Requests per day"
groups={[
{ key: "free", label: "Free" },
{ key: "pro", label: "Pro" },
{ key: "team", label: "Team" },
]}
/>
)
}Bubbles sized by a third number
Cities
Size shows economic output
"use client"
import { ChartScatter } from "@/components/ballmac/chart-scatter"
const data = [
{ city: "Lagos", region: "africa", people: 15.4, growth: 3.2, gdp: 98 },
{ city: "Nairobi", region: "africa", people: 5.1, growth: 3.6, gdp: 32 },
{ city: "Cairo", region: "africa", people: 21.3, growth: 1.9, gdp: 160 },
{ city: "Mumbai", region: "asia", people: 21.7, growth: 1.1, gdp: 310 },
{ city: "Dhaka", region: "asia", people: 23.2, growth: 2.9, gdp: 140 },
{ city: "Jakarta", region: "asia", people: 11.2, growth: 1.4, gdp: 240 },
{ city: "Seoul", region: "asia", people: 9.9, growth: 0.1, gdp: 880 },
{ city: "Berlin", region: "europe", people: 3.7, growth: 0.4, gdp: 210 },
{ city: "Madrid", region: "europe", people: 6.7, growth: 0.5, gdp: 230 },
{ city: "Bogotá", region: "americas", people: 11.5, growth: 1.7, gdp: 130 },
{ city: "Toronto", region: "americas", people: 6.3, growth: 1.3, gdp: 410 },
]
export default function ChartScatterBubble() {
return (
<ChartScatter
className="w-full max-w-2xl"
title="Cities"
description="Size shows economic output"
data={data}
xKey="people"
yKey="growth"
zKey="gdp"
groupKey="region"
nameKey="city"
xLabel="People (millions)"
yLabel="Growth (%)"
zLabel="Output ($bn)"
groups={[
{ key: "africa", label: "Africa" },
{ key: "asia", label: "Asia" },
{ key: "europe", label: "Europe" },
{ key: "americas", label: "Americas" },
]}
categoryLabel="City"
/>
)
}API reference
| Prop | Type | Default |
|---|---|---|
data*One row per point: a number under `xKey` and one under `yKey`, optionally a size, a group and a name. | ChartRow[] | — |
xKey* | string | — |
yKey* | string | — |
zKeyThe key of a number that sets each point's size, which turns the scatter into a bubble chart. | string | — |
groupKeyThe key that says which group a point belongs to. Each group gets a color and a legend item. | string | — |
groupsNames and colors for the groups. Without it every point is one group. | ChartSeries[] | — |
nameKeyThe key of each point's name, shown in the tooltip and the table. | string | — |
xLabel*Axis titles, also used in the tooltip and the table. | string | — |
yLabel* | string | — |
zLabel | string | — |
xFormatter | (value: number) => string | — |
yFormatter | (value: number) => string | — |
xDomain | [number | "auto", number | "auto"] | — |
yDomain | [number | "auto", number | "auto"] | — |
sizeRangeSmallest and largest point size in pixels, when `zKey` is set. | [number, number] | [80, 520] |
grid | "both" | "horizontal" | "none" | "both" |
tooltip | boolean | true |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Arrow keys | Move between points (accessibilityLayer) |
| Tab, Enter / Space | Legend buttons switch series off and on; the Chart / Table switch shows the numbers as a table |
| Screen readers | The figure has a name and a summary written from the data |
| Table | Every point, with its name, group and values, in a table you can download as CSV |
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 3 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
No endless animation to begin with.
Not applicable: Right to left
Not mirrored by design: Recharts draws its axes in SVG coordinates, which do not mirror.
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
13 tab stops, each showing a visible change when it takes focus. 4 documented keys.
Not applicable: Loading, empty and error
Does not show data or take submissions.
Passed: Automated tests
Covered by charts-batch-families.test.tsx.
For information: Footprint
2.9 KB of source (gzip) in 1 file. Installs recharts.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Use with AI
<ChartScatter title description data xKey yKey xLabel yLabel /> with optional zKey (point size), groupKey plus groups ([{ key, label, color? }]), nameKey, xDomain, yDomain, sizeRange, formatters, legend, patterns, state. Every row is one point. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Chart Scatter (@ballmac/chart-scatter) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Relationships between two numbers
- Distributions and outliers
- Bubble charts with a third measure
Not for
- Trends over time (use chart-line)
- Categories with one value each (use chart-bar)
Registry JSON: https://ui.ballmac.com/r/chart-scatter.json
Credits
Free to use in personal and commercial projects.
- npm
- recharts@^3
Pairs well with
Chart Frame
The card around a Recharts chart: a Table view with CSV download, a legend that toggles series, patterns so series differ by more than color, a summary for screen readers, and loading, empty and error states.
Activity Feed
A compact actor and action feed with timestamps and an empty state.
Audio Player
A podcast-style audio player: play and pause, skip back and forward, a scrubber with chapter ticks, a named current chapter and a speed control. Works with a real file or as a silent demo.
Avatar Circles
Overlapping avatars that spread on hover, lift individually with a name and role tooltip, show presence, fall back to initials and end in a +N count that can be a button.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.