ScatterChart

Two quantities against each other as points, optionally grouped by colour and sized by a third value for a bubble chart. Keep colour groups to three — any two points can end up adjacent, which is a harder test than a bar chart's neighbours.

vue
<UiScatterChart :data="..." :xKey="..." :yKey="..." :ariaLabel="..." />

Needs the charts module

This component ships in @jgastager/bundy-ui/charts, a separate module so projects that never draw a chart don't carry the paint classes these need. Nothing extra to install — just add the module entry. See Charts.


Options

One block per prop — what it does, and what it looks like set.

Data

required

data is one object per point, and unlike the series-based charts the rows stay in exactly the shape you have them — nothing needs reshaping into series first. The props below name which fields to read, so the same array can drive a plain scatter, a grouped one and a bubble chart without being touched.

Rows stay in their own shape here, unlike the series-based charts — x-key/y-key name the fields, and series-key/size-key are optional.

X key

required

xKey names the field holding each point's horizontal value. It has to be numeric — a scatter's whole job is placing two quantities against each other, so a categorical x belongs on a bar chart instead. Grid rules stay on by default here, because reading a point means recovering two coordinates rather than one.

Y key

required

yKey names the numeric field for the vertical position. Which of the two quantities goes here is a real choice: convention puts the thing you think is caused on the y axis, so a reader scanning left to right sees the effect follow the cause.

Series key

seriesKey groups the points by a field, one palette slot each. Keep this to three groups: a bar chart only ever puts neighbouring colors side by side, but a scatter can place any two points together, and past three the palette can no longer guarantee two arbitrary groups are distinguishable. Beyond that, facet into small multiples instead.

  • Search
  • Social
  • Email

Keep series-key to three groups. Colours only have to survive against their neighbours in a bar or line chart, but a scatter can place any two points side by side; past three, the palette can't guarantee two arbitrary groups are distinguishable. Facet into small multiples instead of adding a fourth.

Size key

sizeKey turns it into a bubble chart. The value maps to bubble area , not radius — mapping it to radius makes a doubled value look four times as large. fill-opacity sits below 1 by default so overlapping bubbles still show what's underneath.

  • Search
  • Social
  • Email

size-key maps to bubble *area*, not radius — mapping a value to radius directly makes a doubled value look four times as large.

ARIA label

required

ariaLabel is required rather than optional: a cloud of points is completely opaque to a screen reader, so this is the only description of the chart that exists. Name the relationship the scatter shows — that's the finding — rather than the geometry.

ARIA description

ariaDescription carries the detail the label shouldn't — the spread, an outlier, where the relationship breaks down. It's read after the label, so write the two as a headline and a paragraph rather than repeating one inside the other.

Email campaigns sit well above the trend, returning 180 to 310 signups on under $1,000 of spend, while social campaigns return the fewest signups per dollar despite the largest budgets.

Color

color is the paint for the dots when there's no seriesKey to group them: without a grouping field every point means the same thing, so they all take one colour, and this is how that colour stops being primary. It's ignored entirely once seriesKey is set, where colors takes over.

Colors are the library's own semantic ones — primary, secondary, success, warning, error, info, pending, neutral — and any shade of them, primary-400 through primary-950, exactly as the bg-* utilities spell them. Set color per series (or per item) to pin one; leave it unset to take the next slot of the default sequence. neutral and secondary are never handed out automatically, so an unlabelled series never lands on them.

Colors

colors pins a paint per group, matched positionally to the order the groups first appear in data — so reordering your rows reorders the palette. Reach for it when a group has an identity the reader already ties to a colour; groups past the end of the list fall back to the default sequence.

  • Search
  • Social
  • Email

Radius

radius is the dot size in pixels for a plain scatter, four by default. Shrink it when the cloud is dense enough that dots start merging, and grow it when there are only a handful of points and each one is worth pointing at. It's ignored once sizeKey turns the chart into a bubble chart.

Radius range

radiusRange sets the smallest and largest bubble in pixels; everything between is interpolated on area. Widen it when the values span orders of magnitude, narrow it when the points are dense enough that big bubbles start swallowing their neighbours.

Fill opacity

fillOpacity sits below 1 by default because overlap is the normal state of a scatter — it's how you see through a crowded cloud instead of losing whatever is underneath. It's also what hovering fades *relative to*, so the highlight keeps working at any setting.

fill-opacity sits below 1 by default because overlap is the normal state of a scatter. Solid dots hide how many points share a spot.

X label

xLabel titles the horizontal axis. It matters more on a scatter than on most charts: the ticks are bare numbers, and without a title nothing on the plot says what quantity they measure.

Y label

yLabel titles the vertical axis. Set both titles on a scatter as a rule — the pair is what turns two anonymous number lines into a stated relationship.

X tick size

xTickSize is the box the x-tick slot draws into, and the room reserved for it below the plot. Real tick marks are the one thing the default axis leaves out, and they're a line and a label in the slot — box starts at the plot's edge, so a mark drawn from it points back at the axis.

  • Search
  • Social
  • Email

Y tick size

yTickSize is the same for the y-tick slot, taken out of the left margin — and there it replaces the estimate from the label text entirely, so it has to cover everything the slot draws. Height costs nothing on this axis: only the width is reserved.

  • Search
  • Social
  • Email

X format

xFormat formats the horizontal ticks and the x value in the tooltip together, so a unit written once shows up in both. Both axes are numeric here, which is why each gets its own formatter rather than sharing one.

Y format

yFormat does the same for the vertical axis. A scatter's tooltip names both fields it read, so formatted units carry straight into it — worth setting even when the axis ticks are short enough to read bare.

Grid

grid draws rules on both axes and is on by default here, unlike some of the other charts — reading a point means recovering two coordinates, and without rules the eye has to travel to both axes unaided. Drop it only on a thumbnail where nobody is reading values off the plot.

X min

xMin, xMax, yMin and yMax hold an axis open to values the data needn't reach. Small multiples are what they're for: fitted to its own extent, each channel below gets a different scale and the three panels stop being comparable — the same dot lands somewhere else in each.

each fitted to its own data
one shared scale

The four bounds are what make small multiples honest: a scatter fitted to its own extent puts the same point in a different place in every panel. Set the same xMin/xMax/yMin/yMax across a facet grid and the panels become comparable. None of them clamps the data — a point outside a bound widens that axis instead of being drawn past the plot's edge.

X max

xMax is the top of the x axis — a budget ceiling to read the spend against, whether or not a campaign got near it.

Y min

yMin is the floor of the y axis. Zero is the usual choice: it makes the dots' heights readable as counts, rather than as positions inside whatever range this batch happens to span.

Y max

yMax is the ceiling of the y axis, on the same terms. A value outside any of these bounds still widens the axis — a dot drawn past the plot's edge would be a worse answer than a scale that grew.

Tooltip

tooltip is the panel that follows the pointer, on by default, and the only place a point's exact pair of values appears. Turn it off for a decorative distribution thumbnail where the shape is the whole reading.

Hovering dims every dot except the nearest one, to 0.7 of whatever fill-opacity is set to. It's a factor rather than a fixed value because these dots don't rest at 1 — a flat target equal to fill-opacity would make hovering do nothing, and one above it would make the unfocused dots brighter than the focused one. The hit test is nearest-point within 40px in both axes rather than a bounding box, so a dot stays reachable however small size-key made it.

The hover fade is a CSS transition over the library's own 250ms duration, so it costs nothing and honours prefers-reduced-motion. There's no animate prop and nothing tweens on a data change — the marks are plain SVG elements Vue patches directly.

Animate

animate is on by default: the dots grow in on the first draw and glide to their new positions. Roll new values to compare it with the chart on the right, which has it off. Only the marks move — axes, tooltips and labels always read the real numbers — and a reader who has asked their system for reduced motion gets no motion either way.

Legend

legend defaults to on whenever seriesKey is set and off when it isn't, which is almost always right: without grouping there are no identities to name. Pass it explicitly to drop the legend from a grouped chart whose colours are already explained by the surrounding copy.

  • Search
  • Social
  • Email

Legend position

legendPosition moves the legend above the plot instead of below it. Top works when the group identities are what a reader needs before the dots mean anything.

  • Search
  • Social
  • Email

Legend align

legendAlign places the legend horizontally — start by default, so it lines up with the plot's left edge and the axis title beneath it. Centre it when the chart is the only thing in its card and the symmetry reads better.

  • Search
  • Social
  • Email
  • Search
  • Social
  • Email

Legend orientation

legendOrientation flows the entries in a row or a column. Vertical costs height but reads as a list, which is the better shape beside a narrow chart or when the group names are long enough to wrap awkwardly in a row.

  • Search
  • Social
  • Email

Legend group

legendGroup names a group that a standalone UiChartLegend with the same group belongs to, so the legend can sit anywhere — here above the plot — and still dim the other dots while you hover an entry. The chart hands the legend its entries, colors included, so it needs no items. Switch the chart's own legend off if you don't want two.

Guides

guides draws the axes and reserves the margins they need. Turning it off drops all of that — ticks, labels, grid and gutters — leaving the points to fill the box, which is what turns a scatter into a distribution thumbnail inside a card.

Spend vs signups12 campaigns

guides="false" drops the axes, ticks, labels and grid *and* the margins they reserve, so the marks fill the whole box. That plus a small height is the inline-sparkline shape — there's no separate component for it.

Height

height is the plot's height in pixels — width always comes from the container. It's ignored once aspectRatio is set, and it's held through the loading and empty states so a dashboard doesn't reflow as its panels resolve.

Aspect ratio

aspectRatio sizes the chart from its container's width instead of a fixed height. It's worth more on a scatter than on most charts: the shape of a cloud depends on the ratio of the two axes, so a chart that squashes on narrow screens can make a relationship look steeper than it is.

Loading

loading replaces the plot with a skeleton of exactly the same height, so the page doesn't jump when the data lands. An empty data array gets the empty state instead — two separate conditions, worth driving separately.

No data to display

API Reference

Generated from the component's source — props, slots and emits as the component actually declares them.

Props

Prop
Type
Default
Description
datarequired
Record<string, unknown>[]
—
One object per point. Unlike the series charts, rows stay in their own shape.
xKeyrequired
string
—
Field holding each point's x value.
yKeyrequired
string
—
Field holding each point's y value.
string
—
Field grouping points into series, each taking a color. Keep this to **three** groups. A bar chart only ever puts neighbouring colors side by side, but a scatter can place any two points together, and past three the palette can't guarantee two arbitrary groups stay distinguishable. Facet into small multiples instead.
string
—
Field holding a numeric size, turning the chart into a bubble chart.
ariaLabelrequired
string
—
What the chart shows, for screen readers. Required: an unlabelled chart is unreadable.
string
—
A longer accessible description, for detail the label shouldn't carry.
ChartColorName
'primary'
Paint for the dots when there's no `seriesKey` to group them. Without a grouping field every point means the same thing, so they all take one color — this is how that color stops being `primary`. Ignored once `seriesKey` is set, where `colors` takes over.
ChartColorName[]
—
Per-group paints, positionally matched to the order groups first appear in `data`. Same escape hatch the other charts give: reach for it when a group has an identity the reader already ties to a color. Groups past the end of the list fall back to the default sequence.
number
4
Dot radius in pixels. Ignored when `sizeKey` is set.
[number, number]
[4, 18]
Smallest and largest radius, for the `sizeKey` mapping.
number
0.75
Fill opacity. Below 1 because overlap is the normal state of a scatter.
string
—
Title for the x axis.
string
—
Title for the y axis.
number
24
Side of the square box each x tick's slot content draws into, in pixels — and the room reserved for it below the plot. Ignored without the `x-tick` slot: text ticks size themselves.
number
24
The same for the `y-tick` slot, reserved out of the left margin instead.
(value: number) => string
—
Formats x-axis ticks and tooltip values.
(value: number) => string
—
Formats y-axis ticks and tooltip values.
boolean
true
Draws grid rules on both axes — reading a point means recovering two coordinates.
number
—
Lowest and highest value each axis reaches, whether or not the data gets there. Fixing a scale is what makes two scatters comparable — small multiples of the same measure drift apart the moment each one fits its axes to its own extent. A bound holds an axis open; it doesn't cap the data. A point outside one still widens that axis, because a dot drawn outside the plot is a worse answer than a scale that grew.
number
—
Highest value the x axis reaches, on the same terms as `xMin`.
number
—
Lowest value the y axis reaches, on the same terms as `xMin`.
number
—
Highest value the y axis reaches, on the same terms as `xMin`.
boolean
true
Shows the tooltip on hover.
boolean
true
Grows the dots in on the first draw, and glides each to its new position and size whenever the data changes. A dot is the row at the same index of `data`, so keep rows in a stable order for them to move rather than swap. Readers who ask their system for reduced motion get no animation either way.
boolean
—
Shows the legend. Defaults to on whenever `seriesKey` is set.
"top" | "bottom"
'bottom'
Where the legend sits relative to the plot.
"start" | "center" | "end"
'start'
Horizontal placement of the legend.
"horizontal" | "vertical"
'horizontal'
How the legend entries flow.
string
—
Names the group a `<UiChartLegend group="…">` elsewhere on the page belongs to, so hovering its entries dims this chart's other marks. Set it — usually with `:legend="false"` — to place the legend anywhere you like instead of under the plot.
boolean
true
Draws the axes and reserves their margins.
number
320
Height of the plot in pixels. Ignored when `aspectRatio` is set.
number
—
Width-to-height ratio, sizing the chart from its container's width.
boolean
false
Replaces the plot with a skeleton of the same height.

Slots

Slot
default
x-tick
Receives `{ tick, index, x, y, size, box }`.
y-tick
Receives `{ tick, index, x, y, size, box }`.

Types

ts
interface ScatterPoint {
    key: string;
    x: number;
    y: number;
    size: number | null;
    group: string;
    color: ChartColorName;
}
ts
interface ChartPlot {
    x: number;
    y: number;
    width: number;
    height: number;
    /** Right edge, since marks need it more often than the width. */
    right: number;
    /** Bottom edge, which is also the x axis' baseline. */
    bottom: number;
}
ts
type ChartColorName = ChartColorFamily | `${ChartColorFamily}-${ChartColorShade}` | `[${string}]`;