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
LineChartfor trends over a continuous x: lines connect ordered samples of one measure; scatter treats every row as an independent observation. - Prefer
BarChartto 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Composition
Compose the parts of a ScatterPlot together to build your own:
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:
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:
Data attributes
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.
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.
ScatterPlot.XAxis
Value labels along the bottom of the plot. Renderless — painted on canvas; 2D only. Labels that would collide are skipped, never rotated.
ScatterPlot.YAxis
Value tick labels along the left of the plot. Renderless — painted on canvas; 2D only. Ticks land on clean thousands-separated numbers.
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).
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:
Data attributes
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:
Data attributes
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:
While a copy is fresh, an always-mounted role="status" element beside the button announces "Copied" to a screen reader.
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.
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
seriesSlotonScatterPlot.Point. It reserves one built-in color-and-shape pairing before automatic series take the remaining slots. - One color channel — pass
coloronScatterPlot.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.