Skip to main content

Scatter Plot

A canvas-rendered scatter plot for correlating two continuous measures (three with the rotatable 3D projection). Hover hits the nearest point within 24px of the pointer, and the entire interaction contract (the tooltip, keyboard stepping, polite announcements, and a screen-reader description) ships with ScatterPlot.Root unconditionally. The screen-reader data table mounts once the chart takes focus. No part removal can strip keyboard access to the data.

When to use

A ScatterPlot answers "are these two measures related?" (latency vs throughput, payload size vs duration, CPU vs memory). Every mark is one independent observation positioned by two continuous values, so correlation, clusters, and outliers surface as shape. With zKey on the Root it correlates three measures, projecting the points into a rotatable 3D cube.

  • Scatter x values must be continuous: numbers or Dates. There is no categorical scatter axis.
  • Prefer LineChart for trends over a continuous x: lines connect ordered samples of one measure; scatter treats every row as an independent observation.
  • Prefer BarChart to compare values across discrete categories: bars encode value by length from a zero baseline.

Keep it to eight series or fewer. Scatter is an all-pairs chart form: any point can land next to any point from any other series, so every pair of series colors must stay distinguishable, not just the neighbors in a legend. The default chart tokens clear that bar in every theme: all 28 pairs of the eight slots hold 15 ΔE apart for full-color vision and 8 ΔE under simulated protanopia or deuteranopia. Eight point shapes match those eight slots. A series takes its slot's glyph automatically, so a chart at the cap still gives every series its own mark. Past eight, every extra wears the overflow gray and the circle, so fold them into a single "Other" series or facet into small multiples.

Compose everything inside ScatterPlot.Root: gridlines, axes, one ScatterPlot.Point per series, and the legend. Hover anywhere near a point: the chart hits the nearest point within 24px of the pointer, so a 4px dot is never a pinpoint target, and the tooltip names the hit point's series alone. Always compose ScatterPlot.Legend on multi-series charts: series identity must never rely on color-matching alone.

Scatter plot with 80 data points. Use the left and right arrow keys to move between data points, Page Up and Page Down to jump by a tenth of the data, Home and End to jump to the first and last, Enter or Space to activate the current point, and Escape to dismiss the readout.
import { ScatterPlot } from "@ngrok/mantle/scatter-plot"; function createSeededRandom(seed: number) {	// Deterministic LCG (Numerical Recipes constants) — renders identically	// on every load, no Math.random.	let state = seed >>> 0;	return () => {		state = (state * 1664525 + 1013904223) >>> 0;		return state / 4294967296;	};} type RegionOptions = { key: string; seed: number; latencyBase: number }; function regionPoints({ key, seed, latencyBase }: RegionOptions) {	const random = createSeededRandom(seed);	return Array.from({ length: 40 }, () => {		const latency = Math.round(latencyBase + (random() + random()) * 45);		const throughput = Math.round(1180 - latency * 6 + (random() - 0.5) * 160);		return { latency, [key]: throughput };	});} const regionThroughput = [	...regionPoints({ key: "usEast", seed: 11, latencyBase: 18 }),	...regionPoints({ key: "euWest", seed: 47, latencyBase: 62 }),]; function RegionScatterExample() {	return (		<ScatterPlot.Root			data={regionThroughput}			xKey="latency"			aria-label="Latency vs throughput by region"		>			<ScatterPlot.Grid />			<ScatterPlot.XAxis tickFormat={(value) => `${value}ms`} />			<ScatterPlot.YAxis />			<ScatterPlot.Point dataKey="usEast" label="US East" />			<ScatterPlot.Point dataKey="euWest" label="EU West" />			<ScatterPlot.Tooltip valueFormat={(value) => `${value} rps`} />			<ScatterPlot.Legend />		</ScatterPlot.Root>	);}

Point shapes

Eight glyphs match the palette's eight series slots, one glyph per slot. A series that sets no shape wears the glyph of its slot, so a multi-series chart carries a distinct color and a distinct glyph with no consumer effort.

Series slotPaired glyph
chart-1"circle"
chart-2"square"
chart-3"triangle"
chart-4"diamond"
chart-5"triangle-down"
chart-6"plus"
chart-7"cross"
chart-8"star"
chart-other"circle"

The second channel earns its keep in three places where color alone fails:

  • a reader with a color vision deficiency, who cannot hold eight hues apart
  • print and grayscale exports, which flatten every slot to one gray
  • forced-colors mode, which replaces every hue with the user's own

The glyph carries the same identity through all three, and it costs nothing: the mark paints some shape either way.

Set seriesSlot={3} for a fixed triangle identity. Automatic series take the unreserved slots in composition order, and removing one closes the gap. A custom color changes only paint, so the series keeps the glyph of its automatic or fixed slot. The "other" slot uses the circle.

Set shape to override the pairing: an explicit value always wins. Every painted mark carries the circle's fill area, so no glyph reads louder than its siblings. The legend keys and hover dots clip those same silhouettes out of a small box, so a spiky glyph like the star reads lighter there than the square.

The encoding is family-wide (the same prop, glyph set, slot pairing, and equal-weight sizing on every chart), and how much of it you see tracks how much of the mark the glyph occupies. Here the glyph is the mark itself. On a line chart it appears on the canvas point markers (when markers is on) and the hover dot; on an area chart a filled band has no per-point mark, so the hover dot and legend key wear it. Bars carry no glyph at all: a bar's identity is its position, its color, and its texture. Mantle never assigns that texture for you, so bars are the one form with no automatic second channel. Legend keys mirror the glyph on every chart form: the glyph itself for scatter points and areas, the glyph riding a short stroke for lines.

US East and EU West below set no shape, so they wear their slots' circle and square. AP South sets shape="star", which beats the triangle its slot would hand it.

Scatter plot with 120 data points. Use the left and right arrow keys to move between data points, Page Up and Page Down to jump by a tenth of the data, Home and End to jump to the first and last, Enter or Space to activate the current point, and Escape to dismiss the readout.
import { ScatterPlot } from "@ngrok/mantle/scatter-plot"; const shapedRegions = [	...regionPoints({ key: "usEast", seed: 11, latencyBase: 18 }),	...regionPoints({ key: "euWest", seed: 47, latencyBase: 62 }),	...regionPoints({ key: "apSouth", seed: 83, latencyBase: 104 }),]; <ScatterPlot.Root	data={shapedRegions}	xKey="latency"	aria-label="Latency vs throughput by region, shape-coded">	<ScatterPlot.Grid />	<ScatterPlot.XAxis tickFormat={(value) => `${value}ms`} />	<ScatterPlot.YAxis />	<ScatterPlot.Point dataKey="usEast" label="US East" />	<ScatterPlot.Point dataKey="euWest" label="EU West" />	<ScatterPlot.Point dataKey="apSouth" label="AP South" shape="star" />	<ScatterPlot.Tooltip valueFormat={(value) => `${value} rps`} />	<ScatterPlot.Legend /></ScatterPlot.Root>;

Dense scatter

This chart plots 8,000 seeded points in a single series. Rendering degrades by total point count in tiers, never all at once: past 5,000 points the dots drop their 2px surface ring (three canvas ops per point become one), and past 20,000 they become fast-path rects with no arc calls at all. Hover stays nearest-point at every tier (the hit test walks the data, not the pixels), so a dense cloud is exactly as inspectable as a sparse one. A single series also means no legend: ScatterPlot.Legend renders nothing for one series, because the chart's title already names it.

Scatter plot with 8000 data points. Use the left and right arrow keys to move between data points, Page Up and Page Down to jump by a tenth of the data, Home and End to jump to the first and last, Enter or Space to activate the current point, and Escape to dismiss the readout.
import { ScatterPlot } from "@ngrok/mantle/scatter-plot"; function createSeededRandom(seed: number) {	// Deterministic LCG (Numerical Recipes constants) — renders identically	// on every load, no Math.random.	let state = seed >>> 0;	return () => {		state = (state * 1664525 + 1013904223) >>> 0;		return state / 4294967296;	};} function seededRequests(count: number, seed: number) {	const random = createSeededRandom(seed);	return Array.from({ length: count }, () => {		const size = Math.round(2 + random() * random() * 510);		const duration = Math.max(2, Math.round(6 + size * 0.55 + (random() + random() - 1) * 90));		return { size, duration };	});} const requestDurations = seededRequests(8_000, 20_260_719); function DenseScatterExample() {	return (		<ScatterPlot.Root			data={requestDurations}			xKey="size"			aria-label="Response duration by payload size, 8,000 requests"		>			<ScatterPlot.Grid />			<ScatterPlot.XAxis tickFormat={(value) => `${value}KB`} />			<ScatterPlot.YAxis tickFormat={(value) => `${value}ms`} />			<ScatterPlot.Point dataKey="duration" label="Duration" />			<ScatterPlot.Tooltip valueFormat={(value) => `${value}ms`} />		</ScatterPlot.Root>	);}

3D scatter

Set zKey and the scatter renders inside a rotatable cube: drag anywhere in the plot to orbit the camera. The drag captures the pointer, and pitch is clamped so the cube never flips upside down; points depth-sort under a gentle perspective, so nearer points paint over farther ones as the cloud turns. A tap without dragging still inspects and activates the nearest point.

Axis parts and reference lines apply to the 2D projection only and are ignored in 3D; the cube frame carries orientation, with each edge labeled by its data key. The hover readout carries the point's z value, and keyboard stepping and the sr-only data table work identically in 3D: arrows walk the projected cloud point by point, announcements append the z value, and the table grows a z column.

The glyphs carry into the projection too, and each one depth-sorts with its point. None of the three series below sets shape, so they wear their slots' circle, square, and triangle. The redundant encoding earns its keep in 3D more than anywhere: rotation constantly changes which clouds overlap, and the glyphs keep every series identifiable at any camera angle, with or without color vision.

Scatter plot with 96 data points. Use the left and right arrow keys to move between data points, Page Up and Page Down to jump by a tenth of the data, Home and End to jump to the first and last, Enter or Space to activate the current point, and Escape to dismiss the readout.
import { ScatterPlot } from "@ngrok/mantle/scatter-plot"; function createSeededRandom(seed: number) {	// Deterministic LCG (Numerical Recipes constants) — renders identically	// on every load, no Math.random.	let state = seed >>> 0;	return () => {		state = (state * 1664525 + 1013904223) >>> 0;		return state / 4294967296;	};} type ClusterOptions = {	key: string;	seed: number;	center: { cpu: number; memory: number; connections: number };	spread: { cpu: number; memory: number; connections: number };}; function seededCluster({ key, seed, center, spread }: ClusterOptions) {	const random = createSeededRandom(seed);	const jitter = () => random() + random() - 1;	return Array.from({ length: 32 }, () => ({		cpu: Math.round(center.cpu + jitter() * spread.cpu),		[key]: Math.round(center.memory + jitter() * spread.memory),		connections: Math.round(center.connections + jitter() * spread.connections),	}));} const gatewayNodes = [	...seededCluster({		key: "usEast",		seed: 3,		center: { cpu: 34, memory: 46, connections: 420 },		spread: { cpu: 14, memory: 12, connections: 110 },	}),	...seededCluster({		key: "euWest",		seed: 17,		center: { cpu: 64, memory: 72, connections: 660 },		spread: { cpu: 12, memory: 10, connections: 90 },	}),	...seededCluster({		key: "apSoutheast",		seed: 29,		center: { cpu: 80, memory: 34, connections: 240 },		spread: { cpu: 9, memory: 12, connections: 80 },	}),]; function Scatter3dExample() {	return (		<ScatterPlot.Root			data={gatewayNodes}			xKey="cpu"			zKey="connections"			aria-label="Gateway nodes by CPU, memory, and connections"		>			<ScatterPlot.Point dataKey="usEast" label="US East" />			<ScatterPlot.Point dataKey="euWest" label="EU West" />			<ScatterPlot.Point dataKey="apSoutheast" label="AP Southeast" />			<ScatterPlot.Tooltip />			<ScatterPlot.Legend />		</ScatterPlot.Root>	);}

Dimensions

dimensions controls how many axes the cloud currently occupies: the pattern behind "watch the clusters separate" explainers. At 1 every point collapses onto the x axis line and the four clusters below read as one jumbled strip (only their glyphs still tell them apart); at 2 they spread across the plane; at 3 they fill the cube. Each cluster wears the glyph paired to its series slot (circle, square, triangle, diamond), so cluster identity survives even where position collapses. Changing it morphs every point along a smooth glide (the same chase that drives the domains), the y and z frame edges fade in with their dimension, and prefers-reduced-motion snaps the whole move.

Drag to rotate
Scatter plot with 128 data points. Use the left and right arrow keys to move between data points, Page Up and Page Down to jump by a tenth of the data, Home and End to jump to the first and last, Enter or Space to activate the current point, and Escape to dismiss the readout.
import { Button } from "@ngrok/mantle/button";import { ScatterPlot } from "@ngrok/mantle/scatter-plot";import { useState } from "react"; function createSeededRandom(seed: number) {	let state = seed >>> 0;	return () => {		state = (state * 1664525 + 1013904223) >>> 0;		return state / 4294967296;	};} type ClusterOptions = {	key: string;	seed: number;	center: { cpu: number; memory: number; connections: number };	spread: { cpu: number; memory: number; connections: number };}; function seededCluster({ key, seed, center, spread }: ClusterOptions) {	const random = createSeededRandom(seed);	const jitter = () => random() + random() - 1;	return Array.from({ length: 32 }, () => ({		cpu: Math.round(center.cpu + jitter() * spread.cpu),		[key]: Math.round(center.memory + jitter() * spread.memory),		connections: Math.round(center.connections + jitter() * spread.connections),	}));} // Clusters overlap on the x axis but separate in y and z, so each added// dimension visibly untangles them.const dimensionClusters = [	...seededCluster({		key: "clusterA",		seed: 5,		center: { cpu: 40, memory: 25, connections: 75 },		spread: { cpu: 12, memory: 8, connections: 9 },	}),	...seededCluster({		key: "clusterB",		seed: 23,		center: { cpu: 45, memory: 75, connections: 25 },		spread: { cpu: 12, memory: 8, connections: 9 },	}),	...seededCluster({		key: "clusterC",		seed: 41,		center: { cpu: 55, memory: 30, connections: 30 },		spread: { cpu: 12, memory: 8, connections: 9 },	}),	...seededCluster({		key: "clusterD",		seed: 59,		center: { cpu: 60, memory: 70, connections: 80 },		spread: { cpu: 12, memory: 8, connections: 9 },	}),]; function DimensionsExample() {	const [dimensions, setDimensions] = useState<1 | 2 | 3>(1);	return (		<div className="flex w-full flex-col gap-3">			<div className="flex items-center justify-between">				<div className="flex gap-1">					{([1, 2, 3] as const).map((option) => (						<Button							key={option}							type="button"							appearance={dimensions === option ? "filled" : "outlined"}							intent="neutral"							onClick={() => setDimensions(option)}						>							{option}D						</Button>					))}				</div>				<span className="text-muted text-xs">Drag to rotate</span>			</div>			<ScatterPlot.Root				data={dimensionClusters}				xKey="cpu"				zKey="connections"				dimensions={dimensions}				aria-label="Four clusters separating across one, two, and three dimensions"			>				<ScatterPlot.Point dataKey="clusterA" label="Cluster A" />				<ScatterPlot.Point dataKey="clusterB" label="Cluster B" />				<ScatterPlot.Point dataKey="clusterC" label="Cluster C" />				<ScatterPlot.Point dataKey="clusterD" label="Cluster D" />				<ScatterPlot.Tooltip />				<ScatterPlot.Legend />			</ScatterPlot.Root>		</div>	);}

Reference lines

ScatterPlot.ReferenceLine paints a dashed horizontal marker at a y value (a target, a budget, an SLO) in the neutral reference color, with an optional label above its right end. Like the axis parts, it applies to the 2D projection only and is ignored in 3D.

Scatter plot with 12 data points. Use the left and right arrow keys to move between data points, Page Up and Page Down to jump by a tenth of the data, Home and End to jump to the first and last, Enter or Space to activate the current point, and Escape to dismiss the readout.
import { ScatterPlot } from "@ngrok/mantle/scatter-plot"; const coldStarts = [	{ bundleSize: 4.1, startupTime: 148 },	{ bundleSize: 5.6, startupTime: 172 },	{ bundleSize: 7.4, startupTime: 205 },	{ bundleSize: 9.8, startupTime: 288 },	{ bundleSize: 11.2, startupTime: 254 },	{ bundleSize: 12.9, startupTime: 341 },	{ bundleSize: 14.4, startupTime: 312 },	{ bundleSize: 16.2, startupTime: 398 },	{ bundleSize: 17.3, startupTime: 288 },	{ bundleSize: 19.1, startupTime: 452 },	{ bundleSize: 21.5, startupTime: 384 },	{ bundleSize: 24.0, startupTime: 507 },]; function ReferenceLineScatterExample() {	return (		<ScatterPlot.Root			data={coldStarts}			xKey="bundleSize"			aria-label="Cold start time by bundle size"		>			<ScatterPlot.Grid />			<ScatterPlot.XAxis tickFormat={(value) => `${value}MB`} />			<ScatterPlot.YAxis tickFormat={(value) => `${value}ms`} />			<ScatterPlot.Point dataKey="startupTime" label="Cold start" />			<ScatterPlot.ReferenceLine y={300} label="Target" />			<ScatterPlot.Tooltip valueFormat={(value) => `${value}ms`} />		</ScatterPlot.Root>	);}

Copy as markdown

ScatterPlot.CopyButton copies the chart's current rows to the clipboard as a machine-stable, GitHub-flavored markdown table: one x column plus one column per series in paint order, and the z column in 3D (ISO 8601 dates, plain un-separated numbers, an em dash for gaps). The table pastes into Slack, issues, spreadsheets, and LLM chats. The button renders in flow where composed; Root is relatively positioned, so className can dock it over a corner.

Scatter plot with 80 data points. Use the left and right arrow keys to move between data points, Page Up and Page Down to jump by a tenth of the data, Home and End to jump to the first and last, Enter or Space to activate the current point, and Escape to dismiss the readout.
import { ScatterPlot } from "@ngrok/mantle/scatter-plot"; <ScatterPlot.Root	data={regionThroughput}	xKey="latency"	aria-label="Latency vs throughput by region, copyable">	<ScatterPlot.Grid />	<ScatterPlot.XAxis tickFormat={(value) => `${value}ms`} />	<ScatterPlot.YAxis />	<ScatterPlot.Point dataKey="usEast" label="US East" />	<ScatterPlot.Point dataKey="euWest" label="EU West" />	<ScatterPlot.Tooltip valueFormat={(value) => `${value} rps`} />	<ScatterPlot.Legend />	<ScatterPlot.CopyButton className="absolute right-0 top-0" /></ScatterPlot.Root>;

Lists of charts

A page of 100 charts pays for the handful a reader can see. Every ScatterPlot.Root ingests its data and answers the keyboard from its first render, but it paints and allocates its canvas only once it comes within one viewport of the visible area or takes focus. Three viewports away it releases its canvas memory and repaints on the way back; printing paints every chart. The screen-reader data table waits for focus alone: it mounts once a reader tabs into the chart, so a page of sparklines holds no hidden tables until a reader asks for one. Add content-visibility: auto to your list's cards as a complement: it skips style and layout for offscreen cards, and the chart's own gate already skips paint and canvas memory.

Modes

mode sets how much of the chart's contract a Root exposes. Pick by what a reader does with the chart.

modeUse it forA reader gets
"interactive" (default)A chart a reader explores: a dashboard panel, a detail page, a report with a question behind it.A named role="application" Tab stop, hover and keyboard point cursor, a tooltip readout, polite announcements, and an sr-only data table on focus.
"static"A chart a reader glances at while the exact values sit beside it: a sparkline in a table row or a card, a trend in a print report.A named role="img" with a text description (kind, count, range, series), no Tab stop, no hover, no tooltip, no table. animate defaults to false.
"decorative"A placeholder whose values are not information: synthetic points behind an empty state, a hero backdrop.Nothing: hidden from assistive technology, out of the tab order, inert, every series in one neutral fill.

Noninteractive is not decorative. A sparkline beside a total still tells a reader when activity happened and how it varied, so it keeps a name and a description; only its values live in the text beside it. One hundred sparklines in a table as one hundred Tab stops is a focus order no reader wants, and a readout that only a pointer can reach fails WCAG 1.4.13, so "static" offers neither. A chart that is the only source of its values stays "interactive".

Static charts

Set mode="static" for a chart a reader glances at while the exact values sit beside it. The plot is a named role="img": it takes the same aria-label or aria-labelledby an interactive chart does, and its aria-describedby points at the description, so a screen reader hears the chart kind, the point count, the x range, and the series labels without a Tab stop. There is no interaction overlay: no hover band, no tooltip, no keyboard point cursor, no data table, and no live region, and a composed ScatterPlot.Tooltip has no effect. Pointer events pass through to the row or card behind it. The series keep their colors. animate defaults to false, because a list of sparklines that each play a reveal as the reader scrolls is motion with no information in it; pass animate to play the reveal. The interaction props (activeIndex, onActiveIndexChange, onDatumActivate) are forbidden at the type level. The Sparklines in a List recipe is the worked example.

<div className="flex items-center gap-2">	<ScatterPlot.Root		data={service.requests}		xKey="day"		mode="static"		aria-label={`Requests for ${service.name}, last 30 days`}		className="aspect-auto h-6 w-28"	>		<ScatterPlot.Point dataKey="count" label="Requests" />	</ScatterPlot.Root>	<span className="text-muted text-xs tabular-nums">{compact(service.requestTotal)}</span></div>

Decorative charts

Set mode="decorative" to render the chart as a placeholder backdrop instead of real data: synthetic points behind an empty state, an atmospheric hero graphic. A decorative chart keeps its visual rendering and animation but drops every layer that only makes sense for real data: it is hidden from assistive technology, removed from the tab order, and inert to pointer and keyboard, with no hover band, tooltip, sr-only data table, or live region. Because the values aren't information, mode="decorative" is mutually exclusive with the accessible-name props (aria-label / aria-labelledby) and the interaction props (activeIndex, onActiveIndexChange, onDatumActivate); the type won't compile with both.

A decorative chart also drops the categorical palette. Every series paints --color-chart-decorative, one neutral fill that outranks a series' color and seriesSlot. A backdrop carries no series identity, and a hue on values that are not information invites a reader to decode it.

That fill is what keeps an overlaid message readable. Empty.Description sits on text-muted, which measures 4.88:1 on a bare light card (0.38 over the 4.5:1 floor for 14px text), so any tint under that copy spends the headroom. --color-chart-decorative is the darkest neutral step that still clears the floor in both themes: 4.56:1 in light and 4.93:1 in dark. The message needs no scrim behind it, no dimming, and no blur. Redeclare the token on the Root to retune the fill (className="[--color-chart-decorative:var(--color-neutral-100)]") and re-measure the copy against the value you pick.

Reach for mode="decorative" over hand-rolled inert + CSS: it removes the interaction overlay at the source, so consumers never target chart internals to neutralize it. Keep the real empty-state message as a sibling of the chart (not inside it) so screen readers still announce it; the decorative chart itself is silent.

mode="decorative" says the values are not information; it is not a disabled flag (a chart is not a form control). For real data a reader only glances at, use mode="static".

No data to correlate yet

Points will appear here once your endpoints report latency and throughput.

import { Empty } from "@ngrok/mantle/empty";import { ScatterPlot } from "@ngrok/mantle/scatter-plot"; /** Synthetic points — a backdrop, not data — so no Math.random and no real values. */const placeholderCloud = [	[12, 840],	[20, 610],	[28, 720],	[34, 480],	[41, 560],	[47, 390],	[55, 470],	[62, 300],].map(([latency, throughput]) => ({ latency, throughput })); function EmptyStateScatterPlotExample() {	return (		<div className="relative w-full">			<ScatterPlot.Root data={placeholderCloud} xKey="latency" mode="decorative">				<ScatterPlot.Point dataKey="throughput" />			</ScatterPlot.Root>			{/* The real message is a sibling of the chart, so AT still announces it. */}			<Empty.Root className="absolute inset-0 m-auto h-fit w-fit">				<Empty.Title>No data to correlate yet</Empty.Title>				<Empty.Description>					<p>Points will appear here once your endpoints report latency and throughput.</p>				</Empty.Description>			</Empty.Root>		</div>	);}

Composition

Compose the parts of a ScatterPlot together to build your own:

ScatterPlot.Root├── ScatterPlot.Grid├── ScatterPlot.XAxis├── ScatterPlot.YAxis├── ScatterPlot.Point (one per series)├── ScatterPlot.ReferenceLine├── ScatterPlot.Tooltip├── ScatterPlot.Legend└── ScatterPlot.CopyButton

Accessibility

Every interactive chart must have an accessible name: either a direct aria-label or an aria-labelledby reference to a visible title (preferred when the chart sits under a heading: no drift-prone duplicated text). This is enforced at the type level: omitting both is a compile error, not a runtime warning. The name lands on the focusable interaction overlay, the chart's single named element (the canvas itself is hidden from assistive technology so the chart is never announced twice). A static chart names its plot role="img" instead and has no Tab stop. A decorative chart carries no real data, so it takes no accessible name and is hidden from assistive technology entirely.

The chart is a single tab stop with a full keyboard point cursor, identical in 2D and 3D:

KeyBehavior
TabFocus the chart — one tab stop regardless of point count.
ArrowLeft / ArrowRightStep to the previous/next point in x order.
PageUp / PageDownJump forward/backward by 10% of the data.
Home / EndJump to the first/last point.
Enter / SpaceActivate the current point — fires onDatumActivate.
EscapeDismiss the readout.

The tooltip names only the relevant series, never a column of em dashes: under the pointer it reads the hit point's series, and under keyboard stepping it reads the row's populated series. Keyboard stepping announces the active point through a debounced polite aria-live region, so rapid arrowing never floods the screen-reader queue. In 3D, the announcement carries the point's z value.

The overlay's aria-describedby points at a screen-reader description, data-slot="scatter-plot-description", so a screen reader hears what the chart holds when it takes focus: the chart kind, the number of data points, the x range from the first point to the last, the series labels, and the keyboard instructions. For example: "Scatter plot with 120 data points from 1 to 2,400. Series: Checkout, Search. Use the left and right arrow keys to move between data points, …". An empty chart reads "Scatter plot with no data." in place of the count and range. Server HTML carries the plain count alone. The range waits for hydration, because Intl output differs by locale, and the series labels register in layout effects, so both render on the client.

The 3D orbit is pointer-only: there is no keyboard equivalent for rotating the cube. Rotation changes the view, not the data, so a keyboard user still reaches every point through the point cursor and the data table.

Every chart also renders a screen-reader-only data table once it takes focus: one column for the x value and one per series (plus a z column in 3D), bounded to the first 150 rows with a caption stating the summarization. A ScatterPlot.Root mounts dormant and engages once focus lands inside it (the overlay is the Tab stop, and a focusable part composed inside the Root, such as ScatterPlot.CopyButton, counts too), and it never goes dormant again. Visibility paints the chart but never mounts the table. The Root stamps data-state="dormant", then data-state="engaged", and the table mounts with the flip, so a screen reader's table list names only the charts the reader has focused. The description above names what every chart holds, focused or not. Tooltips enhance, they never gate: every plotted value is reachable through the table or the keyboard point cursor.

The table doubles as the chart's machine-readable surface: date row headers carry the exact ISO timestamp in <time dateTime> and value cells carry the raw number in data-value, so agents and scrapers read precise data from the DOM without reverse-engineering locale formatting. Assistive tech still hears the human-formatted text, and the table itself is addressable at data-slot="scatter-plot-data-table". To reach it, an agent focuses the chart (a scroll into view is not enough), then waits for data-state="engaged" on the Root. ScatterPlot.CopyButton hands over the same rows as markdown with neither step.

API Reference

ScatterPlot.Point, ScatterPlot.Grid, ScatterPlot.XAxis, ScatterPlot.YAxis, and ScatterPlot.ReferenceLine are renderless: they register configuration with the Root, which paints everything on canvas. They accept no className and no DOM props. ScatterPlot.Tooltip, ScatterPlot.Legend, and ScatterPlot.CopyButton are the parts that touch real DOM — the first two accept div props, CopyButton accepts button props. Grid, XAxis, YAxis, and ReferenceLine configure the 2D projection only: composing them is always safe, but they are ignored while the chart renders in 3D.

ScatterPlot.Root

The root of a scatter plot: owns the data, the scales, the canvas renderer, and the entire interaction contract. Renders a <div> wrapper (defaulting to a 16:9 aspect ratio, overridable via className) containing the canvas, the focusable interaction overlay, the sr-only description, the crosshair, hover-band, and marker layers, the aria-live region, and, once focus lands inside the chart, the sr-only data table. Hovering hits the nearest point within 24px (never a pinpoint target); the tooltip reads that point's series, x, and y (and z in 3D). Points never stack, so there is no stacked prop.

All props from div, plus:

PropTypeDefaultDescription
datareadonly TDatum[]Rows of data; each row is one point per composed ScatterPlot.Point series.
xKeyExtract<keyof TDatum, string>The key of each row's x value (number or Date).
aria-labelstringThe chart's accessible name. Exactly one of aria-label or aria-labelledby is required — enforced at the type level. Required unless mode is \"decorative\", which forbids it.
aria-labelledbystringThe id of a visible element that names the chart. Preferred over aria-label when the chart sits under a heading. Required unless mode is \"decorative\", which forbids it.
mode?"interactive" | "static" | "decorative""interactive"How much of the contract the Root exposes: explore it ("interactive"), glance at it with its values beside it ("static": a named role="img", no Tab stop, no tooltip, no table, animate off by default), or paint a placeholder ("decorative": hidden from assistive technology, one neutral fill). The name props are required unless decorative, and the interaction props are allowed only when interactive. See Modes.
xScale?"linear" | "time"auto-detectedOverride the auto-detected x scale (Date values → "time", numbers → "linear").
zKey?Extract<keyof TDatum, string>The key of each row's depth value. When set, the scatter renders in 3D: drag the plot to rotate the cube. Axis parts and reference lines apply to the 2D projection only and are ignored in 3D (the cube frame carries orientation).
dimensions?1 | 2 | 333D only: how many axes the cloud currently occupies. Changing it morphs the points onto the x axis line (1), the xy plane (2), or the full cube (3) with a smooth glide; the y and z frame edges fade in with their dimension.
yDomain?[number | "auto", number | "auto"]["auto", "auto"]Override the automatic y domain at either end.
animate?booleantrueAnimate enter/update transitions; the enter transition plays when the chart first comes into view. prefers-reduced-motion always wins.
pending?booleanfalseHold the previous render at reduced opacity while fresh data loads — never swap a chart that has rendered once for a skeleton.
activeIndex?number | nullControlled active datum index (null = none). Omit for uncontrolled hover. Allowed only when mode is "interactive".
onActiveIndexChange?(index: number | null) => voidNotified when hover/keyboard moves the active index. Allowed only when mode is "interactive".
onDatumActivate?(event: ChartDatumEvent) => voidNotified on datum activation: point click/tap, or Enter/Space on the focused chart. The event carries { index, xValue, datum, dataKey }; dataKey names the hit point's series, null for keyboard activation. Allowed only when mode is "interactive".

Data attributes

Data AttributeValueDescription
data-slot"scatter-plot"The Root wrapper, with any forwarded chain ahead of it.
data-slot"scatter-plot-plot"The plot box. Every overlay layer positions against it, and the resize observer measures it.
data-slot"scatter-plot-canvas"The <canvas> the marks paint on. Always aria-hidden. Engine-owned: it is absent from server HTML and from a dormant chart, and the Root inserts it when the chart activates (near the viewport, on focus, or before print).
data-slot"scatter-plot-crosshair"The vertical hairline; a scatter plot never shows it. Engine-owned: it is absent from server HTML and from a dormant chart, and the Root inserts it when the chart activates (near the viewport, on focus, or before print). Interactive charts only: a static or decorative chart inserts the canvas alone.
data-slot"scatter-plot-hover-band"The category highlight; a scatter plot never shows it. Engine-owned: it is absent from server HTML and from a dormant chart, and the Root inserts it when the chart activates (near the viewport, on focus, or before print). Interactive charts only: a static or decorative chart inserts the canvas alone.
data-slot"scatter-plot-markers"The container the engine syncs the hit point's hover dot into. Engine-owned: it is absent from server HTML and from a dormant chart, and the Root inserts it when the chart activates (near the viewport, on focus, or before print). Interactive charts only: a static or decorative chart inserts the canvas alone.
data-slot"scatter-plot-tooltip"The tooltip surface. The Root renders it on the first hover or keyboard step and keeps it; ScatterPlot.Tooltip configures it. A chart nobody points at holds none.
data-slot"scatter-plot-description"The sr-only description the overlay's aria-describedby points at: the chart kind, the point count, the x range, the series labels, and, on an interactive chart, the keyboard instructions. Omitted when mode="decorative".
data-slot"scatter-plot-data-table"The sr-only data table. Mounts once focus lands inside the chart (data-state flips to "engaged") and stays. Interactive charts only.
data-state"dormant" | "engaged"On the Root. Flips to "engaged" once focus lands inside the chart, and never flips back. The data table mounts with it. Visibility paints the chart but never flips it. Interactive charts only.
data-slot"chart-active-point"The hover dot on the hit point, inside the markers layer. Also carries data-shape.
data-shape"circle" | "square" | "triangle" | "diamond" | "triangle-down" | "plus" | "cross" | "star"On the hover dot, naming the glyph its series wears — the shape it set, else the one paired to its series slot. The dot clips itself to that glyph, so target this only to restyle a dot.

ScatterPlot.Point

One point series. Renders nothing itself — it registers the series with the chart, which paints its points on canvas: 4px dots with a 2px surface ring, degrading gracefully past thousands of points (see Dense scatter). Compose one Point per series — eight or fewer with the default tokens, which are validated over every pair, not only neighbors.

PropTypeDefaultDescription
dataKeystringThe row key this series reads its y values from.
label?stringdataKeyDisplay name for the legend, tooltip, and data table.
seriesSlot?1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | "other"next unreserved slotGive the series a fixed color-and-shape identity. See Chart color tokens.
color?SeriesColorcolor from the resolved slotOverride only the color channel with a chart token or CSS color string. This does not change the series slot or default shape. See Chart color tokens.
shape?"circle" | "square" | "triangle" | "diamond" | "triangle-down" | "plus" | "cross" | "star"the glyph paired to the series slotThe glyph this series' points wear, mirrored by the legend key and hover dot. This overrides only the shape channel. See Point shapes.

ScatterPlot.Grid

Hairline gridlines behind the points. Renderless — painted on canvas; 2D only (the 3D cube frame carries orientation). Omit the part to omit the grid.

PropTypeDefaultDescription
lines?"horizontal" | "vertical" | "both""horizontal"Which gridlines to draw.

ScatterPlot.XAxis

Value labels along the bottom of the plot. Renderless — painted on canvas; 2D only. Labels that would collide are skipped, never rotated.

PropTypeDefaultDescription
tickFormat?(value: string | number | Date, context: { defaultLabel: string }) => stringscale-dependentFormat a tick label. Receives the row's x value — a number on linear scales, a Date on time scales. Defaults to thousands-separated numbers and calendar-aware time labels. The second argument is { defaultLabel }: the label the axis paints without a formatter.
tickCount?numberdensity-derivedTarget tick count. Approximate: a linear axis over integer-only x values never renders fractional ticks.

ScatterPlot.YAxis

Value tick labels along the left of the plot. Renderless — painted on canvas; 2D only. Ticks land on clean thousands-separated numbers.

PropTypeDefaultDescription
tickFormat?(value: number, context: { defaultLabel: string; step: number }) => stringthousands-separatedFormat a tick label. The second argument carries the tick step and the defaultLabel the axis paints without a formatter, so a formatter can add a unit to defaultLabel and keep its precision.
tickCount?number5Maximum tick count. When omitted, the axis aims for about 5 ticks. The axis can render fewer: ticks land on human-friendly steps, a vertical axis keeps about 28px for each tick, and a label that would overlap its neighbor is skipped. Integer-valued data never renders fractional ticks.

ScatterPlot.ReferenceLine

A dashed horizontal marker at a y value — a target, a budget, an SLO threshold. Renderless — painted on canvas; 2D only (ignored in the 3D projection).

PropTypeDefaultDescription
ynumberThe y value the line marks.
label?stringA short label painted above the line's right end.
color?SeriesColorneutral referenceLine color. Defaults to the neutral reference color in the active theme.

ScatterPlot.Tooltip

Customizes the hover/keyboard tooltip readout. The tooltip itself is part of ScatterPlot.Root's interaction contract and renders even when this part is absent — compose it to format the label or values, append a footer, or replace the content entirely.

A children readout renders inside a <div data-slot="scatter-plot-tooltip-label">. When it goes away, React removes that div instead of your own text nodes: a browser translation engine reparents text nodes, and a removal aimed at one throws. The div is display: contents, so it adds no box and your content stays a direct layout child of the parent. To style it as a box, set a display of your own on it first. Browser translation covers the mechanism and the rules your own components need.

The footer and children render props receive a HoverSnapshot: the active index, xValue, the original datum, points, viaKeyboard — and zValue in 3D. Scatter points are filtered to the relevant series: the hit point's series under the pointer, the row's populated series under the keyboard.

All props from div (forwarded to the tooltip element the Root renders) except children, plus:

PropTypeDefaultDescription
labelFormat?(value: string | number | Date) => ReactNodeFormat the x/label row of the readout.
valueFormat?(value: number, dataKey: string) => ReactNodeFormat each series value.
indicator?"line" | "none""line"The per-series color key: a short stroke or nothing.
footer?ReactNode | ((snapshot: HoverSnapshot) => ReactNode)Extra content appended below the series rows (e.g. a "view logs" hint).
children?(snapshot: HoverSnapshot) => ReactNodeReplace the entire readout.

Data attributes

Data AttributeValueDescription
data-slot"scatter-plot-tooltip-label"On the <div> wrapping a children readout. The default readout has none.

ScatterPlot.Legend

The legend: series names keyed by swatches carrying each series' color and glyph, rendered as real DOM in flow below the plot. Always compose it on multi-series charts — identity must never rely on color-matching alone. It renders nothing for a single series (the chart's title already names it). The legend is at most 64px tall (max-h-16) and scrolls past that, so many series never take the plot's height in a fixed-height Root. Set a max-h-* class in className to change the bound.

A children render prop renders inside a <div data-slot="scatter-plot-legend-label">. When it goes away, React removes that div instead of your own text nodes: a browser translation engine reparents text nodes, and a removal aimed at one throws. The div is display: contents, so it adds no box and your content stays a direct layout child of the parent. To style it as a box, set a display of your own on it first. Browser translation covers the mechanism and the rules your own components need.

All props from div except children, plus:

PropTypeDefaultDescription
children?(series: SeriesMeta[]) => ReactNodeReplace the default legend items. Receives the registered series with their dataKey, label, mark, resolved color, and resolved shape.

Data attributes

Data AttributeValueDescription
data-slot"scatter-plot-legend"The legend list. ScatterPlot.Legend renders it in flow below the plot.
data-slot"scatter-plot-legend-label"On the <div> wrapping a children render prop. The default legend has none.
data-shape"circle" | "square" | "triangle" | "diamond" | "triangle-down" | "plus" | "cross" | "star"On each series' swatch, naming the glyph its ScatterPlot.Point wears. The swatch clips itself to that glyph, so target this only to restyle a key.

ScatterPlot.CopyButton

A button that copies the chart's current data to the clipboard as a GitHub-flavored markdown table: one x column, one column per series in paint order, plus the z column in 3D. Values are machine-stable — ISO 8601 dates, plain un-separated numbers, an em dash for gaps — so the paste target receives parseable data, not locale formatting. Renders an icon button in flow where composed; Root is relatively positioned, so className can dock it over a corner (e.g. absolute right-0 top-0).

All props from button, plus:

PropTypeDefaultDescription
label?string"Copy data as Markdown"The accessible name, rendered as the button's aria-label.
onCopy?(value: string) => voidCallback fired after a successful copy, passes the serialized markdown.
onCopyError?(error: unknown) => voidCallback fired when an error occurs during copying.
asChild?booleanfalseCompose the copy behavior onto your own element instead of the default button.

While a copy is fresh, an always-mounted role="status" element beside the button announces "Copied" to a screen reader.

Data AttributeValueDescription
data-slot"scatter-plot-copy-button"The button the part renders.

Chart color tokens

The ordered --color-chart-1…--color-chart-8 tokens define the built-in series identities. Each of the two themes (light and dark) authors its own eight oklch() literals, measured so all 28 pairs hold 15 ΔE apart for full-color vision and 8 ΔE under simulated protanopia or deuteranopia, and so every slot clears 3:1 against that theme's card surface. The semantic ramps are deliberately excluded, so a series never reads as a status color.

Series receive unreserved slots in composition order. Set seriesSlot to give a series a fixed visual identity. Mantle reserves fixed slots before automatic series take the remaining slots. Removing an automatic series closes the gap. The color and shape props override only their own channels and never change slot assignment. seriesSlot="other" selects the neutral tail treatment without reserving a categorical slot. When the chart runs out of unreserved categorical slots, later automatic series paint with --color-chart-other.

All-pairs is the scope scatter needs. A line, bar, or area series can only neighbor the series stacked or drawn beside it, but a scatter mark can land next to any other mark, so its colors must survive every comparison rather than the neighboring ones. The eight slots are gated at that scope in both themes, which is why the cap in When to use is the palette's own eight. Fold the long tail into a single "Other" series or facet into small multiples rather than inventing a ninth hue.

Composition order decides which unreserved slots automatic series get. It does not decide whether they stay apart — the defaults hold at every pair, in any order. A color you set yourself is the one the gates never see, so measure it against the slots its siblings take, or give every series its own shape.

CSS VariableDescription
--color-chart-1The first categorical series slot (blue).
--color-chart-2The second categorical series slot (green).
--color-chart-3The third categorical series slot (pink).
--color-chart-4The fourth categorical series slot (red-orange).
--color-chart-5The fifth categorical series slot (teal).
--color-chart-6The sixth categorical series slot (magenta).
--color-chart-7The seventh categorical series slot (violet).
--color-chart-8The eighth categorical series slot (amber).
--color-chart-otherThe fold slot, worn once more than eight series show at once (neutral gray). Collapse the long tail into a single "Other" series.
--color-chart-decorativeThe neutral fill every series wears on a decorative chart, in place of its categorical slot.

The Bar Chart page records what each theme measures over those 28 pairs.

The color escape hatch accepts any CSS color string, but static colors (a raw hex) do not adapt across themes — prefer a var(...) reference to a theme-aware token, and validate custom palettes for surface contrast and for colorblind-safe separation, just like the defaults; for scatter that means every pair, not just neighbors. A series that carries good/bad meaning — an error rate, a pass/fail split — should wear the semantic status colors (e.g. var(--color-danger-600)), never a categorical slot.

Series identity and color

Three public paths control a series identity or color.

  • Fixed identity — pass seriesSlot on ScatterPlot.Point. It reserves one built-in color-and-shape pairing before automatic series take the remaining slots.
  • One color channel — pass color on ScatterPlot.Point. It takes a chart token by name or any CSS color string without changing the slot or default shape.
  • One chart — redeclare the token on ScatterPlot.Root: className="[--color-chart-1:var(--color-brand)]". Every series that lands in slot 1 picks the new value up. So do its legend key and hover dot.

The chart resolves every token from Root's computed style in one read, inside that Root's own cascade, so a declaration scoped to one Root wins for that chart alone. Sibling charts on the page keep the theme value. A bare currentColor resolves from Root's computed color. A value the canvas cannot parse by itself, such as light-dark() or a currentColor nested inside color-mix(), resolves through a hidden element inside Root instead. Declare the same custom property higher up (on a dashboard wrapper, or at :root) to retheme a whole subtree.

An overridden color leaves mantle's gates behind. Measure the value you set against every other color on the plot, because scatter marks meet at every pair. EU West fixes slot 4 for a diamond identity, then overrides only its paint.

Scatter plot with 80 data points. Use the left and right arrow keys to move between data points, Page Up and Page Down to jump by a tenth of the data, Home and End to jump to the first and last, Enter or Space to activate the current point, and Escape to dismiss the readout.
import { ScatterPlot } from "@ngrok/mantle/scatter-plot"; const regionThroughput = [	...regionPoints({ key: "usEast", seed: 11, latencyBase: 18 }),	...regionPoints({ key: "euWest", seed: 47, latencyBase: 62 }),]; <ScatterPlot.Root	data={regionThroughput}	xKey="latency"	// An app declares its brand token once, high in the tree. This example	// declares it beside the override so it stands alone. A theme that needs	// its own value takes a variant: `dark:[--color-brand:…]`.	className="[--color-brand:#d07c00] [--color-chart-1:var(--color-brand)]"	aria-label="Latency vs throughput by region, first slot rethemed">	<ScatterPlot.Grid />	<ScatterPlot.XAxis tickFormat={(value) => `${value}ms`} />	<ScatterPlot.YAxis />	<ScatterPlot.Point dataKey="usEast" label="US East" />	<ScatterPlot.Point		dataKey="euWest"		label="EU West"		seriesSlot={4}		color="var(--color-accent-600)"	/>	<ScatterPlot.Tooltip valueFormat={(value) => `${value} rps`} />	<ScatterPlot.Legend /></ScatterPlot.Root>;