Chart Bubble
A four-state bubble chart in plain SVG: area-proportional radii from a zero baseline, hue paired with fill texture for the fourth dimension, and an optional time slider that interpolates positions between snapshots.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-bubble.jsonPrompt
Build a React + TypeScript + Tailwind "ChartBubble" card in plain SVG (no chart
library) with zod for the contract. All geometry is computed by small pure
functions that live beside the schema.
Contract
- One zod schema is the source of truth, and the props are z.infer of it plus
presentation-only extras:
{ status: "loading" | "empty" | "error" | "ready";
title: string; description?: string;
x: { label: string; unit?: string; domain?: [number, number] };
y: { label: string; unit?: string; domain?: [number, number] };
size: { label: string; unit?: string; max?: number };
frames: { id: string; label: string;
points: { id: string; label: string; x: number; y: number;
size: number >= 0; group?: string }[] }[] }
- size is a magnitude and the schema refuses a negative one: an area of -3 does
not exist. A signed quantity becomes Math.abs(value) with the sign moved into
`group`, which is a channel that can carry it.
- `id` is the identity of one entity ACROSS snapshots. It is what the time
transition interpolates along, so the schema also rejects the same id twice
inside one snapshot, and duplicate snapshot ids.
- One frame is a plain bubble chart and no time control is rendered; two or more
turn the slider on. Non-ready states carry an empty frames array.
- Extra props: minRadius (default 3, clamped 0-14), maxRadius (default 40,
clamped 10-120), transitionMs (default 700, clamped 0-4000), locale
(default "en-US"), formatX / formatY / formatSize, onSelect, onRetry.
- Ship these pure helpers next to the schema: bubbleRadius(), resolveAxisScale(),
niceStep(), niceFloor(), pearson() and tweenFrames().
Behavior
- AREA, NEVER RADIUS. r = maxRadius * sqrt(value / maxValue). Readers compare
bubbles by the ink they cover and ink is pi*r^2, so encoding the value in the
radius squares every ratio: 10 against 40 is a 1:4 radius, which is 1:16 of
ink — a four-fold difference published as a sixteen-fold one. The sqrt keeps
twice the value at twice the ink.
- ZERO BASELINE, not a fitted one. The area scale runs 0 -> maxValue. Fitting it
to [min, max] instead makes the ratio of two areas arbitrary, and ratios are
the only thing this channel can say: a 90 next to a 100 would come out as an
invisible dot next to a full bubble.
- MINIMUM RADIUS is the one deliberate exception. Under minRadius a bubble is a
dot with no hover target and no visible hue, so tiny values are floored. Be
honest about the trade: inside the floor the area no longer encodes anything
(in the demo a 900 against a 486,000 is 1.72 units of radius, painted at 3), so
the exact number stays in the readout and the table, and minRadius={0} gives
the strict scale back — including a radius of 0 for a value of 0.
- ONE DOMAIN FOR EVERY SNAPSHOT. Fit x and y across all frames at once, never
per frame. A re-fitted domain slides the whole population sideways between two
snapshots in which nothing moved, which is the one thing an animated chart
must not do. A fixed domain goes further and makes two separate renders
comparable; points outside a fixed domain are not drawn (they would land on
the axes and the legend) and are counted out loud in the footer instead.
- TRANSITION: keep an integer target index and a floating "position". A change
of target animates position with rAF over transitionMs * min(2, distance) on
an ease-in-out cubic; retargeting mid-flight starts from wherever the paint
had got to, because the ref is written inside the rAF callback and read when
the next one starts. Interpolate the pair floor(position) / floor(position)+1
by id: values are mixed, identities are not — group (and therefore colour)
flips at the halfway mark, because there is no such thing as half a category.
An entity present in only one of the two snapshots fades on presence
(opacity), never on radius: shrinking it to nothing would read as "this
segment collapsed to zero" instead of "it was not measured that quarter".
- SETTLED VS PAINTED. The readout, the aria labels, the summary and the table
always describe the settled snapshot, never the interpolated instant, so a
reader is never handed a made-up number. Marks that only exist mid-flight are
aria-hidden, pointer-events:none and not focusable.
- PLAYBACK is a chain of one setTimeout per step (hold = transitionMs + 340),
re-armed by the effect each time the index changes and cleared on pause and on
unmount; it stops itself at the last snapshot, and Play from the end restarts
from the first. Scrubbing the slider pauses playback.
- DRAW ORDER is largest radius first, so small bubbles land on top and stay both
visible and hoverable. This is the one ordering rule a bubble chart cannot
skip.
- Under prefers-reduced-motion the transition is skipped entirely — the slider,
the arrows and playback all still work, they just cut between snapshots. The
media query is read with useSyncExternalStore, because a JS-driven tween
cannot be reached by a `motion-reduce:` class.
- DEGENERATE CASES, each handled on purpose: no finite values -> the axis falls
back to 0-1; every value identical (one point, or a tied column) -> the domain
is padded to +-50% of its own magnitude so the point lands mid-plot instead of
dividing by a zero span; every magnitude 0 -> every bubble is drawn at
minRadius and the size legend says so instead of drawing a reference; a point
with a non-finite coordinate is dropped and counted, never coerced to 0; a
snapshot with no points keeps the chart and the slider on screen and says
"Nothing measured in <label>", because dropping to the zero state would take
the slider away and strand the reader.
- Four states are first-class branches of one bg-card panel: a fixed-geometry
pulsing skeleton (aria-hidden, plus an sr-only role=status), an empty state, an
error state with a "Try again" button only when onRetry exists, and ready.
Rendering & styling
- Layout is one <svg viewBox="0 0 760 430" preserveAspectRatio="xMidYMid meet">
at width:100%, height:auto, so it is responsive with no measurement and cannot
collapse in a flex parent. The size legend is a SECOND svg with the SAME
viewBox width and the same full-bleed width, so it scales by exactly the plot's
factor and its radii really are the plot's radii — a legend drawn at another
scale is a lie.
- Inset the pixel RANGE (not the domain) by the largest radius, so a bubble
sitting on the domain edge still fits inside the frame whole.
- Colour: var(--chart-1..5) cycling by group. Colour is never the only channel —
each group also gets an SVG <pattern> fill (flat, hatch up, dots, hatch down,
rules) and the pair advances as a Latin square, pattern = (i + floor(i/5)) % 5,
so 25 groups pass before a (hue, texture) combination repeats. Textures
survive greyscale and colour blindness; the tint inside the pattern is the hue
at 40% and the stroke is the token at full strength, which is what carries the
3:1 boundary contrast while the translucent fill lets overlaps read.
- Pattern ids come from useId with non-alphanumerics stripped, so two instances
on one page never share a paint server.
- Labels are printed inside a bubble only when they fit: the widest run that fits
a circle at half-height h is the chord 2*sqrt(r^2 - h^2), and the estimate uses
a deliberately conservative 0.62em advance because over-estimating only drops a
label while under-estimating overflows one. Everything else is named in the
readout, in the floating label of the active bubble (paint-order: stroke with a
--card halo) and in the table.
- Accessibility: the plot is a <figure> with aria-labelledby on the card heading
and aria-describedby on an sr-only summary that states the FINDING — count,
axes, the largest bubble and its share, both ranges, and the direction and
strength of the unweighted Pearson r (or an honest "too few points"). All the
chrome inside the svg is aria-hidden so the a11y tree is not tick soup. Every
settled mark is a focusable <g> with role="img" (or role="button" when onSelect
is wired) and a full aria-label. Below it, an sr-only WRAPPER DIV — never the
table itself, CSS width is only a lower bound for a table box — holds a real
table of every value and its share.
- Keyboard: one roving tab stop into the plot, held as an entity id so it
survives a reordered snapshot. ArrowLeft / ArrowRight walk the x-sorted order,
ArrowUp / ArrowDown the y-sorted order (up means a higher y), Home / End jump
to the extremes of x, Enter / Space fire onSelect. The ends do not wrap.
Focus draws a dashed ring in --ring over a --card halo, above every bubble, so
a focused mark can never be buried. Hover sets the readout without moving the
tab stop; the readout line itself is aria-hidden because the focused mark
already announces the same words.
- The slider is a native <input type="range"> with aria-valuetext set to the
snapshot label; its height is deliberately not overridden, because Chrome clips
the thumb to the element box. Prev / next use aria-disabled plus a guard in the
handler, never the disabled attribute, which would blur the button the reader
is standing on at the ends of the range.
- Cleanup: the rAF is cancelled and the playback timeout cleared on unmount and
on every dependency change; the reduced-motion listener is unsubscribed by
useSyncExternalStore.
Customization levers
- minRadius: 0 for a strictly area-proportional scale (right when the widest to
narrowest ratio stays under about 400:1), 5-8 when the tail matters more than
the arithmetic. It changes paint only, never a number.
- maxRadius: the density dial. Drop it toward 24 for a crowded board, raise it
toward 60 when a dozen bubbles have room to breathe; the range inset and the
legend follow it automatically.
- size.max: fix it to compare two charts side by side, or to stop one outlier
from flattening everything else; anything above the ceiling is drawn at the
maximum radius and counted in the footer.
- x.domain / y.domain: fix them for the same reason. Leave them out and the
chart fits itself to the data across every snapshot.
- transitionMs: 0 for a cut, 1200+ for a slow explainer. Playback hold follows
it. Autoplay is deliberately not offered — motion the reader did not ask for
is motion they cannot read.
- Palette: re-point the five chart tokens, or map group -> index yourself to pin
a brand colour to a specific segment. Swap the pattern set for solid fills only
if the chart is never printed and never read in greyscale.
- Density: the four paddings, the tick font and the 5-tick target are the numbers
that control how much room the chrome takes; drop the frame watermark and the
in-bubble labels for a compact card.
- Interaction: onSelect turns every mark into a real button (drill into a
segment); wire it to a route or a detail panel. Without it the marks stay
role="img" and nothing pretends to be clickable.Concepts
- Area encoding from a zero baseline — the third dimension is the ink a bubble covers, so the radius is
sqrt(value / max)and the scale starts at 0. Encode it in the radius instead and every ratio is squared; fit the scale to[min, max]instead and the ratio of two areas stops meaning anything at all. - Radius floor — a deliberate break in that proportionality, bought for discoverability: below the floor a bubble has no hover target and no visible hue. The exact value stays in the readout and the table, and setting the floor to 0 restores the strict scale, invisible dots included.
- Shared domain across snapshots — both axes are fitted once over every frame. Re-fitting per frame would make the whole population drift between two snapshots in which nothing moved, which turns the animation into noise.
- Id-matched interpolation — the transition tweens entities, not rows: values are mixed by id while the category snaps at the halfway point, and an entity that exists in only one of the two snapshots fades on opacity rather than shrinking, so "not measured" never reads as "collapsed to zero".
- Settled snapshot — everything textual (aria labels, the summary, the table, the readout) describes a real observation, never an interpolated instant, so a reader is never handed a number that was never measured.
- Roving tab stop over two orders — one tab stop enters the plot and then the arrow keys walk two total orders of the same marks, left/right along x and up/down along y, with the stop held as an entity id so it survives a snapshot that reorders its points.
Streamgraph
A four-state streamgraph whose bands stack on a wiggle-minimising baseline — inside-out ordering, overshoot-free monotone curves, a per-column readout and an sr-only series-by-tick table.
Population Pyramid
A four-state back-to-back band chart — two cohorts mirrored on one shared scale, with per-side totals, an earlier period overlaid as dashed outlines, and every band keyboard-reachable with a readout.