Scatter Chart
- Stable
- WCAG 2.2 evidence
- RTL
A scatter (bubble) chart: points on two numeric axes, optionally sized by a third measure, split into
series and into quadrants. Data driven: series in, SVG out; the same props render the static SVG on the server and the plot is laid
out for the width of its container. Very large sets are thinned for drawing, never for the table. Accessibility: the figure is named by title and described by summary; the data table lists EVERY point with its series, x, y, size
and quadrant (WCAG 1.1.1, 1.3.1). Points differ by colour AND marker glyph per series and the legend shows both (1.4.1); a point
with a name has it written beside it, and the quadrant is text, not only a line. The plot is ONE tab stop with a roving focus. The
arrow keys move to the nearest point in that direction on screen: only points beyond the current one in that direction count, the
ones straight ahead are preferred to nearer ones off to the side, and every press makes progress, so there is no trap and no loop
(2.1.1, 2.1.2). Points hidden behind another in a dense cluster are reached with Page Down and Page Up, which walk every point in
reading order (left to right, top to bottom), and Home and End jump to the first and last. Hovering or focusing a point shows a
tooltip with its values; it stays while hovered and closes with Escape (1.4.13). Bubbles have a stroke as well as a light fill. The
draw-in is behind motion-safe:; the plot stays left-to-right in right-to-left pages while the card mirrors, and digits follow the
locale. Axis ticks thin out at narrow widths (1.4.10). Consumer duties: a title that asks the question, a summary with the main message, units and titles on both axes, saying what the
bubble size and the quadrants mean.
import { ScatterChart } from "@nexera-ui/react";- 10
- examples
- 29
- props
- 4
- live controls
- 1
- platform
- 10
- WCAG criteria
- 0
- blocks use it
Try every prop. Copy the code.
Change the props and the code updates. Check light and dark, LTR and RTL, and phone width.
import { ScatterChart } from "@nexera-ui/react";
<ScatterChart />Examples 9
The same examples as Storybook, rendered live. Open Code to copy one.
With legend
Each department in its own series shows in the legend with its own marker glyph (circle, square, diamond, triangle) as well as its colour, and can be hidden from it.
import { ScatterChart, type ScatterChartProps } from "@nexera-ui/react";
const DEPARTMENTS = [
{ name: "Platform", x: 4.6, y: 0.91, size: 34 },
{ name: "Payroll", x: 3.9, y: 0.82, size: 22 },
{ name: "Sales", x: 4.2, y: 0.66, size: 38 },
{ name: "Support", x: 2.9, y: 0.58, size: 30 },
{ name: "Design", x: 3.3, y: 0.88, size: 17 },
{ name: "Ops", x: 3.1, y: 0.7, size: 24 },
{ name: "Finance", x: 4.4, y: 0.76, size: 20 },
{ name: "Marketing", x: 2.7, y: 0.78, size: 15 },
];
const departmentSeries: ScatterSeries[] = DEPARTMENTS.map((d) => ({
name: d.name,
data: [{ x: d.x, y: d.y, size: d.size, label: d.name }],
}));
const figmaData = {
title: "Engagement vs performance",
description: "Bubble size is team headcount; quadrants split at the medians",
summary:
"Eight departments by performance score and engagement. Platform and Payroll are stars, with high scores and high engagement; Support and Ops are at risk, below the median on both. Sales performs well but engagement is below the median.",
series: departmentSeries,
xAxis: {
label: "Performance score (1 to 5)",
ticks: [2.5, 3, 3.5, 4, 4.5, 5],
valueFormat: { minimumFractionDigits: 1 },
},
yAxis: { ticks: [0.5, 0.65, 0.8, 0.95], valueFormat: { style: "percent" }, label: "Engagement" },
sizeLabel: "Headcount",
quadrants: {
x: "median",
y: "median",
labels: {
topRight: { text: "Stars", tone: "success" },
bottomLeft: { text: "At risk", tone: "danger" },
topLeft: "Engaged, developing",
bottomRight: "High output, low morale",
},
},
isLegendHidden: true,
} satisfies ScatterChartProps;
<ScatterChart title={figmaData.title} summary={figmaData.summary} series={departmentSeries} />;
Series
A plain scatter in three series (no bubble sizes, no quadrants): each series has its own colour and marker glyph, shown in the legend.
import { ScatterChart } from "@nexera-ui/react";
function wave(
seed: number,
count: number,
centre: [number, number],
spread: number,
): { x: number; y: number }[] {
let state = seed;
const next = (): number => {
state = (state * 1664525 + 1013904223) % 4294967296;
return state / 4294967296;
};
return Array.from({ length: count }, () => ({
x: centre[0] + (next() + next() - 1) * spread,
y: centre[1] + (next() + next() - 1) * spread,
}));
}
<ScatterChart
title="Tenure and salary"
summary="Salary rises with tenure in all three departments; Engineering pays most at every tenure."
series={[
{ name: "Engineering", data: wave(1, 40, [5, 95], 30) },
{ name: "Sales", data: wave(2, 40, [4, 70], 22) },
{ name: "Support", data: wave(3, 40, [3, 50], 15) },
]}
description="Every employee, by department"
xAxis={{ label: "Years at the company" }}
yAxis={{
label: "Salary (thousand)",
valueFormat: { style: "currency", currency: "USD", maximumFractionDigits: 0 },
}}
pointRadius={5}
/>;
Loading
import { ScatterChart, type ScatterChartProps } from "@nexera-ui/react";
const DEPARTMENTS = [
{ name: "Platform", x: 4.6, y: 0.91, size: 34 },
{ name: "Payroll", x: 3.9, y: 0.82, size: 22 },
{ name: "Sales", x: 4.2, y: 0.66, size: 38 },
{ name: "Support", x: 2.9, y: 0.58, size: 30 },
{ name: "Design", x: 3.3, y: 0.88, size: 17 },
{ name: "Ops", x: 3.1, y: 0.7, size: 24 },
{ name: "Finance", x: 4.4, y: 0.76, size: 20 },
{ name: "Marketing", x: 2.7, y: 0.78, size: 15 },
];
const departmentSeries: ScatterSeries[] = DEPARTMENTS.map((d) => ({
name: d.name,
data: [{ x: d.x, y: d.y, size: d.size, label: d.name }],
}));
const figmaData = {
title: "Engagement vs performance",
description: "Bubble size is team headcount; quadrants split at the medians",
summary:
"Eight departments by performance score and engagement. Platform and Payroll are stars, with high scores and high engagement; Support and Ops are at risk, below the median on both. Sales performs well but engagement is below the median.",
series: departmentSeries,
xAxis: {
label: "Performance score (1 to 5)",
ticks: [2.5, 3, 3.5, 4, 4.5, 5],
valueFormat: { minimumFractionDigits: 1 },
},
yAxis: { ticks: [0.5, 0.65, 0.8, 0.95], valueFormat: { style: "percent" }, label: "Engagement" },
sizeLabel: "Headcount",
quadrants: {
x: "median",
y: "median",
labels: {
topRight: { text: "Stars", tone: "success" },
bottomLeft: { text: "At risk", tone: "danger" },
topLeft: "Engaged, developing",
bottomRight: "High output, low morale",
},
},
isLegendHidden: true,
} satisfies ScatterChartProps;
<ScatterChart
title={figmaData.title}
summary={figmaData.summary}
series={departmentSeries}
state={{ status: "loading" }}
/>;
Empty
import { ScatterChart, type ScatterChartProps } from "@nexera-ui/react";
const DEPARTMENTS = [
{ name: "Platform", x: 4.6, y: 0.91, size: 34 },
{ name: "Payroll", x: 3.9, y: 0.82, size: 22 },
{ name: "Sales", x: 4.2, y: 0.66, size: 38 },
{ name: "Support", x: 2.9, y: 0.58, size: 30 },
{ name: "Design", x: 3.3, y: 0.88, size: 17 },
{ name: "Ops", x: 3.1, y: 0.7, size: 24 },
{ name: "Finance", x: 4.4, y: 0.76, size: 20 },
{ name: "Marketing", x: 2.7, y: 0.78, size: 15 },
];
const departmentSeries: ScatterSeries[] = DEPARTMENTS.map((d) => ({
name: d.name,
data: [{ x: d.x, y: d.y, size: d.size, label: d.name }],
}));
const figmaData = {
title: "Engagement vs performance",
description: "Bubble size is team headcount; quadrants split at the medians",
summary:
"Eight departments by performance score and engagement. Platform and Payroll are stars, with high scores and high engagement; Support and Ops are at risk, below the median on both. Sales performs well but engagement is below the median.",
series: departmentSeries,
xAxis: {
label: "Performance score (1 to 5)",
ticks: [2.5, 3, 3.5, 4, 4.5, 5],
valueFormat: { minimumFractionDigits: 1 },
},
yAxis: { ticks: [0.5, 0.65, 0.8, 0.95], valueFormat: { style: "percent" }, label: "Engagement" },
sizeLabel: "Headcount",
quadrants: {
x: "median",
y: "median",
labels: {
topRight: { text: "Stars", tone: "success" },
bottomLeft: { text: "At risk", tone: "danger" },
topLeft: "Engaged, developing",
bottomRight: "High output, low morale",
},
},
isLegendHidden: true,
} satisfies ScatterChartProps;
<ScatterChart
title={figmaData.title}
summary={figmaData.summary}
series={[]}
state={{
status: "empty",
title: "No departments to compare",
description: "Choose a period with survey results.",
}}
/>;
Error state
import { Button, ScatterChart, type ScatterChartProps } from "@nexera-ui/react";
const DEPARTMENTS = [
{ name: "Platform", x: 4.6, y: 0.91, size: 34 },
{ name: "Payroll", x: 3.9, y: 0.82, size: 22 },
{ name: "Sales", x: 4.2, y: 0.66, size: 38 },
{ name: "Support", x: 2.9, y: 0.58, size: 30 },
{ name: "Design", x: 3.3, y: 0.88, size: 17 },
{ name: "Ops", x: 3.1, y: 0.7, size: 24 },
{ name: "Finance", x: 4.4, y: 0.76, size: 20 },
{ name: "Marketing", x: 2.7, y: 0.78, size: 15 },
];
const departmentSeries: ScatterSeries[] = DEPARTMENTS.map((d) => ({
name: d.name,
data: [{ x: d.x, y: d.y, size: d.size, label: d.name }],
}));
const figmaData = {
title: "Engagement vs performance",
description: "Bubble size is team headcount; quadrants split at the medians",
summary:
"Eight departments by performance score and engagement. Platform and Payroll are stars, with high scores and high engagement; Support and Ops are at risk, below the median on both. Sales performs well but engagement is below the median.",
series: departmentSeries,
xAxis: {
label: "Performance score (1 to 5)",
ticks: [2.5, 3, 3.5, 4, 4.5, 5],
valueFormat: { minimumFractionDigits: 1 },
},
yAxis: { ticks: [0.5, 0.65, 0.8, 0.95], valueFormat: { style: "percent" }, label: "Engagement" },
sizeLabel: "Headcount",
quadrants: {
x: "median",
y: "median",
labels: {
topRight: { text: "Stars", tone: "success" },
bottomLeft: { text: "At risk", tone: "danger" },
topLeft: "Engaged, developing",
bottomRight: "High output, low morale",
},
},
isLegendHidden: true,
} satisfies ScatterChartProps;
<ScatterChart
title={figmaData.title}
summary={figmaData.summary}
series={departmentSeries}
state={{
status: "error",
title: "Couldn't load survey results",
description: "Check your connection and try again.",
action: (
<Button variant="secondary" size="sm">
Try again
</Button>
),
}}
/>;
Single point
One point: it sits in the middle of the plot with axes that hold it.
import { ScatterChart } from "@nexera-ui/react";
<ScatterChart
title="The new team"
summary="The new team scores 3.8 with 74 percent engagement."
series={[{ name: "New team", data: [{ x: 3.8, y: 0.74, size: 12, label: "New team" }] }]}
description="One department"
/>;
Many points
3,000 points: a deterministic sample of at most 1,000 is drawn (every occupied region of every series keeps a point, so the outlier survives), a note says so, and the table lists all 3,000. The arrow keys still move to the nearest drawn point.
import { ScatterChart } from "@nexera-ui/react";
function wave(
seed: number,
count: number,
centre: [number, number],
spread: number,
): { x: number; y: number }[] {
let state = seed;
const next = (): number => {
state = (state * 1664525 + 1013904223) % 4294967296;
return state / 4294967296;
};
return Array.from({ length: count }, () => ({
x: centre[0] + (next() + next() - 1) * spread,
y: centre[1] + (next() + next() - 1) * spread,
}));
}
function crowd(count: number): ScatterSeries[] {
const a = wave(11, count, [30, 30], 28);
const b = wave(12, count, [70, 62], 24);
const c = wave(13, count, [55, 20], 20);
return [
{ name: "Segment A", data: [...a, { x: 98, y: 96, label: "Outlier" }] },
{ name: "Segment B", data: b },
{ name: "Segment C", data: c },
];
}
<ScatterChart
title="Response time and satisfaction"
summary="Satisfaction falls as response time grows; one ticket is a clear outlier."
series={crowd(1000)}
description="Every support ticket of the quarter"
xAxis={{ label: "Response time (minutes)" }}
yAxis={{ label: "Satisfaction (points)" }}
/>;
Long labels
Long axis titles, series names and point names: the legend and the header wrap, names that do not fit are left out beside the bubbles, the table has them all.
import { ScatterChart, type ScatterChartProps } from "@nexera-ui/react";
const DEPARTMENTS = [
{ name: "Platform", x: 4.6, y: 0.91, size: 34 },
{ name: "Payroll", x: 3.9, y: 0.82, size: 22 },
{ name: "Sales", x: 4.2, y: 0.66, size: 38 },
{ name: "Support", x: 2.9, y: 0.58, size: 30 },
{ name: "Design", x: 3.3, y: 0.88, size: 17 },
{ name: "Ops", x: 3.1, y: 0.7, size: 24 },
{ name: "Finance", x: 4.4, y: 0.76, size: 20 },
{ name: "Marketing", x: 2.7, y: 0.78, size: 15 },
];
const departmentSeries: ScatterSeries[] = DEPARTMENTS.map((d) => ({
name: d.name,
data: [{ x: d.x, y: d.y, size: d.size, label: d.name }],
}));
const figmaData = {
title: "Engagement vs performance",
description: "Bubble size is team headcount; quadrants split at the medians",
summary:
"Eight departments by performance score and engagement. Platform and Payroll are stars, with high scores and high engagement; Support and Ops are at risk, below the median on both. Sales performs well but engagement is below the median.",
series: departmentSeries,
xAxis: {
label: "Performance score (1 to 5)",
ticks: [2.5, 3, 3.5, 4, 4.5, 5],
valueFormat: { minimumFractionDigits: 1 },
},
yAxis: { ticks: [0.5, 0.65, 0.8, 0.95], valueFormat: { style: "percent" }, label: "Engagement" },
sizeLabel: "Headcount",
quadrants: {
x: "median",
y: "median",
labels: {
topRight: { text: "Stars", tone: "success" },
bottomLeft: { text: "At risk", tone: "danger" },
topLeft: "Engaged, developing",
bottomRight: "High output, low morale",
},
},
isLegendHidden: true,
} satisfies ScatterChartProps;
<ScatterChart
title="Engagement against performance score across every department, team and reporting line of the company"
summary={figmaData.summary}
series={[
{
name: "Customer success and customer onboarding operations",
data: [{ x: 4.1, y: 0.82, size: 40, label: "Customer success and onboarding operations" }],
},
{
name: "Platform engineering and infrastructure reliability",
data: [{ x: 4.4, y: 0.9, size: 30, label: "Platform engineering and infrastructure" }],
},
{
name: "Sales development representatives",
data: [{ x: 3.1, y: 0.6, size: 22, label: "Sales development representatives" }],
},
]}
description="Bubble size is the number of people in the team including contractors and part-time employees"
xAxis={{ label: "Performance score from the most recent calibration cycle, one to five" }}
yAxis={{ label: "Engagement from the quarterly survey", valueFormat: { style: "percent" } }}
quadrants={{ x: 3.5, y: 0.75, labels: { topRight: "Stars", bottomLeft: "At risk" } }}
/>;
Right to left
Right-to-left page: the card, legend and table mirror, the plot keeps its left-to-right axes (Right still means right on the screen), digits follow the locale.
import { ScatterChart } from "@nexera-ui/react";
<ScatterChart
title="المشاركة مقابل الأداء"
summary="الهندسة والرواتب نجوم، والدعم في خطر."
series={[
{ name: "الهندسة", data: [{ x: 4.6, y: 0.91, size: 34, label: "الهندسة" }] },
{ name: "المبيعات", data: [{ x: 4.2, y: 0.66, size: 38, label: "المبيعات" }] },
{ name: "الدعم", data: [{ x: 2.9, y: 0.58, size: 30, label: "الدعم" }] },
{ name: "التصميم", data: [{ x: 3.3, y: 0.88, size: 17, label: "التصميم" }] },
]}
description="حجم الفقاعة هو عدد أعضاء الفريق؛ الأرباع تنقسم عند الوسيط"
xAxis={{
label: "درجة الأداء (١ إلى ٥)",
ticks: [2.5, 3, 3.5, 4, 4.5, 5],
valueFormat: { minimumFractionDigits: 1 },
}}
yAxis={{ label: "المشاركة", ticks: [0.5, 0.65, 0.8, 0.95], valueFormat: { style: "percent" } }}
sizeLabel="عدد الأشخاص"
quadrants={{
x: "median",
y: "median",
labels: {
topRight: { text: "نجوم", tone: "success" },
bottomLeft: { text: "في خطر", tone: "danger" },
},
}}
labels={{ point: "النقطة", series: "المجموعة", quadrant: "الربع" }}
table={{ label: "عرض البيانات كجدول" }}
/>;
Props 29
Press "Try it" on a card to load that prop into the playground.
29 props shown
series*Nexerareadonly ScatterSeries[]The points, in one or more series. A series draws in its own colour and marker glyph; Figma's engagement chart has one series per
department with one labelled bubble each. valueFormat, formatValue and formatAxisValue are not props of this chart: each axis
has its own (xAxis, yAxis), because the two measures have different units.
xAxisNexeraScatterAxisThe x axis: title, edges, ticks and number format.
yAxisNexeraScatterAxisThe y axis: title, edges, ticks and number format.
sizeLabelNexerastringName of what the bubble size stands for ("Headcount"): the tooltip row, the point label and the table column. Translate it.
sizeValueFormatNexeraNumberFormatOptionsIntl.NumberFormat options for the bubble sizes.
formatSizeNexera(value: number) => stringFormats a bubble size for the tooltip, the point label and the table. Wins over sizeValueFormat.
quadrantsNexeraScatterQuadrantsDividing lines and quadrant names.
pointLabelsNexera"auto" | "all" | "none"Whether to write the name of each labelled point beside its bubble: auto does it for up to 40 drawn points, all tries for every
labelled point, none never. A name that does not fit without touching another name or leaving the plot is left out; the tooltip,
the point label and the table always have it.
maxPointsNexeranumberThe most points drawn (and focusable). With more, a deterministic sample is drawn that keeps the coverage of the data (every occupied region of every series keeps a point, so outliers survive) and a note under the plot says how many are shown. The data table always lists EVERY point, so nothing is hidden from assistive technology or from anyone who wants the numbers.
samplingNoteNexerastringThe note under the plot when points are left out. {shown} and {total} are replaced by the formatted counts. Translate it.
maxBubbleRadiusNexeranumberRadius in px of the largest bubble. The others follow the square root of their size.
pointRadiusNexeranumberRadius in px of a point without a size.
labelsNexeraPartial<ScatterLabels>Texts the chart invents: table headers and tooltip rows. Translate them.
title*NexerastringChart title: the question the chart answers ("Headcount"). Required: it names the figure, the plot and the data table (WCAG 1.1.1).
descriptionNexeraReactNodeSecondary line under the title.
summary*NexerastringOne or two sentences with what the chart shows and its main message ("Headcount grew from 96 to 128 over twelve months"). Required: it describes the figure for assistive technology (WCAG 1.1.1); the data table has the details.
rangeNexeraChartRangePropsRange switch in the header: 3M, 6M, 12M. Pass the data of the chosen range back in.
actionsNexeraReactNodeExtra controls in the header, after the range switch.
kpiNexeraChartKpiPropsKPI row above the plot: the headline number with an optional label or change badge.
stateNexeraChartPlotStateThe state that replaces the plot: loading (skeleton), empty or error. A chart without a value to plot shows the empty state
by itself; pass { status: "empty", title, description, action } to give it your wording.
tableNexeraChartTableOptionsThe data table that is the text alternative: caption, first-column header and whether it is visible or only for assistive technology.
localeNexerastringLocale of digits and separators.
heightNexeranumberHeight of the plot in px. The card grows with the header, KPI and legend around it.
isLegendHiddenNexerabooleanHide the legend. The series stay named in the tooltip, the point labels and the table.
plotLabelNexerastringAccessible name of the plot group. Translate it.
noDataLabelNexerastringText for a missing value in the tooltip and the table. Translate it.
hiddenSeriesNexerareadonly string[]Names of the hidden series (controlled). Pair it with onHiddenSeriesChange.
defaultHiddenSeriesNexerareadonly string[]Names of the series hidden at first (uncontrolled).
onHiddenSeriesChangeNexera(hidden: readonly string[]) => voidCalled with the names of the hidden series when the user toggles a legend entry.
* Required. React Aria props shown are the ones most apps use; the component accepts the rest of its React Aria props too.
Accessible by default.
Built on React Aria, and covered by the WCAG 2.2 evidence generated on every build.
WCAG 2.2 evidence
9 direct · 1 supporting- 1.1.1Non-text ContentLevel A · tested directly
- 1.3.1Info and RelationshipsLevel A · tested directly
- 1.4.1Use of ColorLevel A · tested directly
- 1.4.10ReflowLevel AA · tested directly
- 1.4.13Content on Hover or FocusLevel AA · tested directly
- 2.1.1KeyboardLevel A · tested directly
- 2.1.2No Keyboard TrapLevel A · tested directly
- 2.3.3Animation from InteractionsLevel AAA · supporting test
- 4.1.2Name, Role, ValueLevel A · tested directly
- 4.1.3Status MessagesLevel AA · tested directly
Fits any width.
Nexera components respond to the space they are given. Drag the corner of the frame, or pick a width.
Styling hooks
Pass className to add Tailwind classes (merged last). State is exposed as data attributes, so you can style it with variants like data-pressed:.
<ScatterChart className="shadow-sm" />Used in blocks
No block uses ScatterChart yet.
Related components
- AccordionItemOne collapsible section of an FAQ or settings page.
- AreaChartA stacked area chart: layers piled up over time with the title, range switch, actions and legend of the card.
- AvailabilityRowOne person's availability across a window of the day: an avatar with initials and the name, and a timeline with the busy periods drawn as bars.
- AvatarA person or organisation shown as a photo, initials or an icon.
- AvatarGroupA row of overlapping avatars with a "+N" chip for the rest.
- AvatarLabelAn avatar with a name and an optional subtitle beside it, for a person in a list, a header or a mention.
- BadgeShort status or category label: `Tone` x `Variant` x `Size`, with an optional dot, leading icon and remove button.
- BadgeGroupA pill that pairs a small badge with a short message, for an announcement or a status line.