Bubble chart
Values as the area of circles. On two axes in up to three groups with direct names, packed tightly by group, along a time axis in lanes, or as a matrix of two categories with row totals.
A third value as the size of a circle
A bubble chart adds a third value to a chart as the area of each circle, so cost, conversion and revenue read in one picture. Labels sit directly at the bubbles without colliding, and the component goes beyond the single bubble mode of the scatter chart.
Groups on two axes sets cost per click against conversion rate with revenue as the area, three channels in color and a key that picks one. Packed circles packs help articles by topic, the most read ones largest, with name and views inside every circle that has room. Bubble timeline gives each kind of event a lane and each event a bubble on its date, the area is the number of guests. Bubble matrix lays items by weekday as a grid of bubbles with the value inside each bubble and the total of each row at the end.
Scale bubbles by area, not by diameter, otherwise large values look far bigger than they are. Keep groups to three colors. Label the largest bubbles directly and let the pointer reveal the rest.
Variants
Details
What Bubble chart brings along, read from the registry and the source.
- Category
- Statistical charts
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/bubble-chart.tsx
- Brings along
- utilskitchartchart-figure
- npm packages
- None
- Languages
- English and German, 6 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The BubbleChartProps interface from components/ui/bubble-chart.tsx, with the comments from the code. Also takes every prop of Omit<ChartA11y, 'label'>, ChartFigureProps.
labeloptionalstringAccessible name of the chart. Default: the title.
variantoptionalBubbleVariantgroupspackedtimelinematrix
datarequiredBubbleDatum[]valueLabelrequiredstringName of the size, e.g. "Revenue"
xLabeloptionalstringgroups: names of the x and y measures. matrix: names of the columns and rows.
yLabeloptionalstringformatoptionalIntl.NumberFormatOptionsxFormatoptionalIntl.NumberFormatOptionsyFormatoptionalIntl.NumberFormatOptionsgroupsoptionalstring[]Order of the groups (and so their colors). At most three.
columnsoptionalstring[]matrix: order of the columns and rows
rowsoptionalstring[]highlightoptionalstring[]Bubbles named directly. Default: the five largest (groups) or every one with room.
heightoptionalnumberPlot height in px. Default per variant.
Built-in texts
Bubble chart writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 6 texts
| Key | English | German |
|---|---|---|
| item | English Item | German Eintrag |
| group | English Group | German Gruppe |
| sizeKey | English {label} as area | German {label} als Fläche |
| total | English Total | German Summe |
| summary | English {n} bubbles, {label} from {min} to {max}, largest {top}. | German {n} Blasen, {label} von {min} bis {max}, am größten {top}. |
| date | English Date | German Datum |
Accessibility
Read from the source of the component.
- Bubble chart handles the keys Escape, Arrow up, Arrow down, Arrow left, Arrow right, Home and End itself.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 556 lines
'use client';
import { useMemo, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { CHART_TEXTS, ChartRoot, ChartSurface, ChartTable, ChartTooltip, fill, niceTicks, numberFormatter, parseDate, textWidth, tickFormatter, useEntrance, useIntlLocale, useShown, useWidth, type ChartA11y, type TooltipRow } from '@/components/ui/chart';
import { ChartFigure, HALO, PickLegend, PlotAxes, ShapeKey, linear, pairColor, placeLabels, type ChartFigureProps } from '@/components/ui/chart-figure';
/*
* Values as the area of circles.
* groups x, y and size, up to three groups with color and shape, the
* largest bubbles named directly where they cover nothing
* packed circles packed tightly by group, no axes, name and value
* inside every circle that has room
* timeline one lane per group, bubbles along a time axis, the size is
* the value of that moment
* matrix a grid of two categories, one bubble per cell, the row
* totals at the end
*
* Area, not radius, carries the value. Smaller bubbles are drawn on top
* and every bubble has a 2 px ring in the surface color, so overlaps stay
* readable. scatter-chart has one plain bubble mode, this chart is about
* the sizes themselves.
*/
export type BubbleVariant = 'groups' | 'packed' | 'timeline' | 'matrix';
export interface BubbleDatum {
label: string;
/** The size */
value: number;
/** groups: a number. timeline: a date ("2026-03" or "2026-03-14"). matrix: the column. */
x?: number | string;
/** groups: a number. matrix: the row. */
y?: number | string;
/** groups, packed, timeline: the group (timeline: the lane) */
group?: string;
}
export interface BubbleChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
/** Accessible name of the chart. Default: the title. */
label?: string;
variant?: BubbleVariant;
data: BubbleDatum[];
/** Name of the size, e.g. "Revenue" */
valueLabel: string;
/** groups: names of the x and y measures. matrix: names of the columns and rows. */
xLabel?: string;
yLabel?: string;
format?: Intl.NumberFormatOptions;
xFormat?: Intl.NumberFormatOptions;
yFormat?: Intl.NumberFormatOptions;
/** Order of the groups (and so their colors). At most three. */
groups?: string[];
/** matrix: order of the columns and rows */
columns?: string[];
rows?: string[];
/** Bubbles named directly. Default: the five largest (groups) or every one with room. */
highlight?: string[];
/** Plot height in px. Default per variant. */
height?: number;
className?: string;
}
const TEXTS = {
en: { item: 'Item', group: 'Group', sizeKey: '{label} as area', total: 'Total', summary: '{n} bubbles, {label} from {min} to {max}, largest {top}.', date: 'Date' },
de: { item: 'Eintrag', group: 'Gruppe', sizeKey: '{label} als Fläche', total: 'Summe', summary: '{n} Blasen, {label} von {min} bis {max}, am größten {top}.', date: 'Datum' },
};
type Circle = { x: number; y: number; r: number };
/*
* Server and browser must pack to the same layout, or hydration fails. Math.hypot, sin and cos
* may differ in the last bit between engines, sqrt is exact everywhere. So: sqrt only, and the
* twelve directions as fixed numbers.
*/
const dist = (dx: number, dy: number) => Math.sqrt(dx * dx + dy * dy);
const H = 0.8660254037844386;
const DIRS: [number, number][] = [[1, 0], [H, 0.5], [0.5, H], [0, 1], [-0.5, H], [-H, 0.5], [-1, 0], [-H, -0.5], [-0.5, -H], [0, -1], [0.5, -H], [H, -0.5]];
/**
* Packs circles tightly, largest first: each new circle touches two placed
* ones (or one, for the second circle) at the free spot nearest the middle.
* `aspect` above 1 lets the pack grow wider than tall. Deterministic.
*/
function pack(radii: number[], gap: number, aspect = 1): Circle[] {
const out: Circle[] = [];
const score = (x: number, y: number) => (x / aspect) ** 2 + y ** 2;
const free = (x: number, y: number, r: number) => out.every((c) => dist(c.x - x, c.y - y) >= c.r + r + gap - 1e-6);
radii.forEach((r, i) => {
if (i === 0) return out.push({ x: 0, y: 0, r });
let best: { x: number; y: number } | null = null;
let bestS = Infinity;
const consider = (x: number, y: number) => {
const s = score(x, y);
if (s < bestS && free(x, y, r)) {
bestS = s;
best = { x, y };
}
};
// Touching one circle, at twelve angles
out.forEach((a) => {
DIRS.forEach(([cx, cy]) => consider(a.x + cx * (a.r + r + gap), a.y + cy * (a.r + r + gap)));
});
// Touching two circles
for (let p = 0; p < out.length; p++)
for (let q = p + 1; q < out.length; q++) {
const A = out[p];
const B = out[q];
const ra = A.r + r + gap;
const rb = B.r + r + gap;
const dx = B.x - A.x;
const dy = B.y - A.y;
const d = dist(dx, dy);
if (d === 0 || d > ra + rb || d < Math.abs(ra - rb)) continue;
const a = (ra * ra - rb * rb + d * d) / (2 * d);
const h = Math.sqrt(Math.max(0, ra * ra - a * a));
const mx = A.x + (a * dx) / d;
const my = A.y + (a * dy) / d;
consider(mx + (h * dy) / d, my - (h * dx) / d);
consider(mx - (h * dy) / d, my + (h * dx) / d);
}
const b = best as { x: number; y: number } | null;
out.push(b ? { x: b.x, y: b.y, r } : { x: 0, y: 0, r });
});
// Center on the enclosing box
const x0 = Math.min(...out.map((c) => c.x - c.r));
const x1 = Math.max(...out.map((c) => c.x + c.r));
const y0 = Math.min(...out.map((c) => c.y - c.r));
const y1 = Math.max(...out.map((c) => c.y + c.r));
return out.map((c) => ({ x: c.x - (x0 + x1) / 2, y: c.y - (y0 + y1) / 2, r: c.r }));
}
const enclose = (cs: Circle[]) => Math.max(...cs.map((c) => dist(c.x, c.y) + c.r), 0);
/**
* Bubbles: on two axes in groups, packed, along a timeline or in a matrix.
* <BubbleChart variant="packed" valueLabel="Visits" data={[{ label: 'Blog', value: 1840, group: 'Content' }, …]} />
*/
/** A tick formatter with as many decimals as the ticks need (0.25 stays 0.25), at most three */
function exactTicks(intl: string, ticks: number[], format?: Intl.NumberFormatOptions) {
const scale = format?.style === 'percent' ? 100 : 1;
let digits = 0;
while (digits < 3 && !ticks.every((v) => Math.abs(Math.round(v * scale * 10 ** digits) - v * scale * 10 ** digits) < 1e-6)) digits++;
const nf = new Intl.NumberFormat(intl, { useGrouping: true, ...format, notation: 'compact', minimumFractionDigits: 0, maximumFractionDigits: Math.max(1, digits) });
return (n: number) => nf.format(n);
}
export function BubbleChart(props: BubbleChartProps) {
const { variant = 'groups', data, valueLabel, xLabel, yLabel, format, xFormat, yFormat, highlight, height: heightProp, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
const t = useTexts(TEXTS, undefined);
useTexts(CHART_TEXTS, labels);
const intl = useIntlLocale();
const fmt = numberFormatter(intl, format);
const tick = tickFormatter(intl, format);
const fx = numberFormatter(intl, xFormat);
const fy = numberFormatter(intl, yFormat);
const [wrapRef, width] = useWidth<HTMLDivElement>(760);
const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
const [active, setActive] = useState<number | null>(null);
const { shown, open, jump } = useShown(active);
const [focusGroup, setFocusGroup] = useState<number | null>(null);
const phone = width < 520;
const name = label ?? title ?? valueLabel;
const n = data.length;
const delay = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);
const groups = (props.groups ?? [...new Set(data.map((d) => d.group ?? ''))].filter(Boolean)).slice(0, variant === 'timeline' ? 12 : 3);
const gi = (d: BubbleDatum) => Math.max(0, groups.indexOf(d.group ?? ''));
const colorOf = (d: BubbleDatum) => (variant === 'timeline' || variant === 'matrix' ? 'var(--kitch-1)' : pairColor(gi(d)));
const dimmed = (d: BubbleDatum) => focusGroup !== null && variant !== 'timeline' && variant !== 'matrix' && gi(d) !== focusGroup;
const vMax = Math.max(1e-9, ...data.map((d) => Math.max(0, d.value)));
const dateF = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', month: 'long', year: 'numeric' });
const dayF = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' });
const xName = (d: BubbleDatum) => {
if (variant === 'timeline') {
const dt = parseDate(d.x);
return dt ? (String(d.x).length === 7 ? dateF : dayF).format(dt) : String(d.x ?? '');
}
return String(d.x ?? '');
};
// Packing is the expensive part, it runs once per data set, not on every pointer move
const groupKey = groups.join('|');
const [packsMemo, outerMemo] = useMemo(() => {
if (variant !== 'packed') return [null, null];
const keys = groups.length ? groups : [''];
const packs = keys.map((g) => {
const idx = data.map((d, i) => (groups.length ? ((d.group ?? '') === g ? i : -1) : i)).filter((i) => i >= 0).sort((x, y) => data[y].value - data[x].value);
const cs = pack(idx.map((i) => Math.sqrt(Math.max(0, data[i].value) / vMax) * 10), 0.3);
return { g, idx, cs, R: enclose(cs) };
});
// Groups side by side on wide screens, more stacked on phones; room above each for its name
return [packs, pack(packs.map((p) => p.R + 1.2), 3.2, phone ? 0.8 : 2.4)] as const;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variant, data, groupKey, vMax, phone]);
/* ---------------- geometry per variant ---------------- */
let height = heightProp ?? 360;
let pos: Circle[] = [];
let axes: React.ReactNode = null;
let order: number[] = data.map((_, i) => i);
let groupRings: { x: number; y: number; r: number; label: string; k: number }[] = [];
let rowTotals: { y: number; text: string; label: string }[] = [];
let cols = 0;
if (variant === 'groups') {
height = heightProp ?? (phone ? 320 : 380);
const xs = data.map((d) => Number(d.x ?? 0));
const ys = data.map((d) => Number(d.y ?? 0));
const rMax = Math.max(14, Math.min(34, width * 0.042));
const padX = (Math.max(...xs) - Math.min(...xs) || 1) * 0.1;
const padY = (Math.max(...ys) - Math.min(...ys) || 1) * 0.14;
const nx = niceTicks(Math.min(...xs) >= 0 ? Math.max(0, Math.min(...xs) - padX) : Math.min(...xs) - padX, Math.max(...xs) + padX, Math.max(3, Math.min(7, Math.floor((width - 60) / 80))));
const ny = niceTicks(Math.min(...ys) >= 0 ? Math.max(0, Math.min(...ys) - padY) : Math.min(...ys) - padY, Math.max(...ys) + padY, 5);
// Ticks like 0.25 need two decimals, the shared compact format keeps one and would print 0.3
const txs = exactTicks(intl, nx.ticks, xFormat);
const yLabels = ny.ticks.map(exactTicks(intl, ny.ticks, yFormat));
const left = Math.max(...yLabels.map((s) => textWidth(s, 11)), 14) + 14;
const right = width - Math.max(10, textWidth(txs(nx.ticks[nx.ticks.length - 1]), 11) / 2 + 4);
const top = 30;
const bottom = height - 40;
const X = linear(nx.min, nx.max, left, right);
const Y = linear(ny.min, ny.max, bottom, top);
pos = data.map((d, i) => ({ x: X(xs[i]), y: Y(ys[i]), r: Math.max(4, round(Math.sqrt(Math.max(0, d.value) / vMax) * rMax)) }));
order = data.map((_, i) => i).sort((a, b) => xs[a] - xs[b] || ys[a] - ys[b]);
axes = <PlotAxes left={left} right={right} top={top} bottom={bottom} height={height} yTicks={ny.ticks.map((v, i) => ({ at: Y(v), label: yLabels[i] }))} xTicks={nx.ticks.map((v) => ({ at: X(v), label: txs(v) }))} xGrid yTitle={yLabel} xTitle={xLabel} />;
} else if (variant === 'packed') {
// Pack each group, then pack the groups (memoised above)
const packs = packsMemo!;
const outer = outerMemo!;
const bx0 = Math.min(...outer.map((c) => c.x - c.r));
const bx1 = Math.max(...outer.map((c) => c.x + c.r));
const by0 = Math.min(...outer.map((c) => c.y - c.r));
const by1 = Math.max(...outer.map((c) => c.y + c.r));
const labelRoom = groups.length ? 22 : 0;
const fitW = (width - 8) / Math.max(1e-6, bx1 - bx0);
height = heightProp ?? round(Math.min(phone ? 520 : 480, Math.max(260, fitW * (by1 - by0) + labelRoom + 8)), 0);
const s = Math.min(fitW, (height - labelRoom - 8) / Math.max(1e-6, by1 - by0));
const cx = width / 2 - ((bx0 + bx1) / 2) * s;
const cy = labelRoom + (height - labelRoom) / 2 - ((by0 + by1) / 2) * s;
pos = new Array(n);
packs.forEach((p, k) => {
const o = outer[k];
p.idx.forEach((i, j) => {
pos[i] = { x: round(cx + (o.x + p.cs[j].x) * s), y: round(cy + (o.y + p.cs[j].y) * s), r: round(Math.max(2, p.cs[j].r * s)) };
});
if (p.g) groupRings.push({ x: round(cx + o.x * s), y: round(cy + o.y * s), r: round((p.R + 0.8) * s), label: p.g, k: groups.indexOf(p.g) });
});
order = data.map((_, i) => i).sort((a, b) => data[b].value - data[a].value);
} else if (variant === 'timeline') {
const lanes = groups.length ? groups : [''];
const laneH = phone ? 58 : 66;
const top = 26;
height = heightProp ?? top + lanes.length * laneH + 30;
const labelW = phone ? 0 : Math.min(180, Math.max(...lanes.map((g) => textWidth(g, 12)), 40) + 22);
const times = data.map((d) => parseDate(d.x)?.getTime() ?? 0);
const lo = Math.min(...times);
const hi = Math.max(...times);
const rMax = Math.min(laneH * 0.48, Math.max(12, (width - labelW) / Math.max(8, new Set(times).size) * 0.55));
const left = labelW + rMax + 4;
const right = width - rMax - 4;
const X = (v: number) => round(hi === lo ? (left + right) / 2 : left + ((v - lo) / (hi - lo)) * (right - left));
const laneY = (k: number) => round(top + k * laneH + laneH / 2 + (phone ? 8 : 0));
pos = data.map((d, i) => ({ x: X(times[i]), y: laneY(Math.max(0, lanes.indexOf(d.group ?? ''))), r: Math.max(3, round(Math.sqrt(Math.max(0, d.value) / vMax) * rMax)) }));
order = data.map((_, i) => i).sort((a, b) => Math.max(0, lanes.indexOf(data[a].group ?? '')) - Math.max(0, lanes.indexOf(data[b].group ?? '')) || times[a] - times[b]);
// Month ticks
const monthF = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', month: 'short' });
const d0 = new Date(lo);
const tks: { at: number; label: string }[] = [];
for (let m = 0; m < 400; m++) {
const at = Date.UTC(d0.getUTCFullYear(), d0.getUTCMonth() + m, 1);
if (at > hi) break;
if (at >= lo) tks.push({ at: X(at), label: monthF.format(new Date(at)) });
}
const every = Math.max(1, Math.ceil(30 / Math.max(1, (right - left) / Math.max(1, tks.length))));
axes = (
<g className="text-[11px]" fill="var(--muted-foreground)">
{lanes.map((g, k) => (
<g key={g || k}>
<line x1={phone ? 0 : labelW} x2={width} y1={laneY(k)} y2={laneY(k)} stroke="var(--kitch-grid)" strokeWidth="1" shapeRendering="crispEdges" />
{g && (
<text x={0} y={phone ? laneY(k) - laneH / 2 + 4 : laneY(k)} dy="0.34em" className="text-[12px] font-medium" fill="var(--foreground)">
{g}
</text>
)}
</g>
))}
{tks.map((tk, i) =>
i % every === 0 ? (
<text key={tk.at} x={tk.at} y={height - 6} textAnchor="middle">
{tk.label}
</text>
) : null,
)}
</g>
);
} else {
// matrix
const colNames = props.columns ?? [...new Set(data.map((d) => String(d.x ?? '')))];
const rowNames = props.rows ?? [...new Set(data.map((d) => String(d.y ?? '')))];
cols = colNames.length;
const labelW = Math.min(phone ? 96 : 170, Math.max(...rowNames.map((s) => textWidth(s, 12)), 30) + 16);
const totalW = phone ? 0 : 70;
const cellW = (width - labelW - totalW) / Math.max(1, colNames.length);
const cellH = Math.max(40, Math.min(64, cellW * 0.9));
const top = 30;
height = heightProp ?? round(top + rowNames.length * cellH + 8, 0);
const rMax = Math.min(cellW, cellH) / 2 - 3;
pos = data.map((d) => {
const c = colNames.indexOf(String(d.x ?? ''));
const r = rowNames.indexOf(String(d.y ?? ''));
return { x: round(labelW + cellW * (c + 0.5)), y: round(top + cellH * (r + 0.5)), r: Math.max(2, round(Math.sqrt(Math.max(0, d.value) / vMax) * rMax)) };
});
order = data.map((_, i) => i).sort((a, b) => rowNames.indexOf(String(data[a].y)) - rowNames.indexOf(String(data[b].y)) || colNames.indexOf(String(data[a].x)) - colNames.indexOf(String(data[b].x)));
const colLabelFit = Math.max(...colNames.map((s) => textWidth(s, 11))) <= cellW - 4;
rowTotals = rowNames.map((rn, r) => ({ y: round(top + cellH * (r + 0.5)), label: rn, text: fmt(data.filter((d) => String(d.y) === rn).reduce((a, d) => a + d.value, 0)) }));
axes = (
<g className="text-[11px]" fill="var(--muted-foreground)">
{rowNames.map((rn, r) => (
<g key={rn}>
{r > 0 && <line x1={0} x2={width} y1={round(top + cellH * r)} y2={round(top + cellH * r)} stroke="var(--kitch-grid)" strokeWidth="1" shapeRendering="crispEdges" />}
<text x={0} y={round(top + cellH * (r + 0.5))} dy="0.34em" className="text-[12px]" fill="var(--foreground)">
{rn}
</text>
</g>
))}
{colNames.map((cn2, c) => (
<text key={cn2} x={round(labelW + cellW * (c + 0.5))} y={14} textAnchor="middle">
{colLabelFit ? cn2 : cn2.slice(0, Math.max(1, Math.floor((cellW - 4) / 6.4)))}
</text>
))}
{!phone && (
<text x={width} y={14} textAnchor="end" className="font-medium">
{t.total}
</text>
)}
{!phone &&
rowTotals.map((rt) => (
<text key={rt.label} x={width} y={rt.y} dy="0.34em" textAnchor="end" className="text-[12px] font-semibold tabular-nums" fill="var(--foreground)">
{rt.text}
</text>
))}
</g>
);
}
/* ---------------- labels ---------------- */
const named = (() => {
if (variant === 'packed' || variant === 'matrix') return [];
const list = highlight ?? [...data].sort((a, b) => b.value - a.value).slice(0, phone ? 3 : 5).map((d) => d.label);
return data.map((_, i) => i).filter((i) => list.includes(data[i].label));
})();
const spots = placeLabels(
named.map((i) => ({ x: pos[i].x, y: pos[i].y, r: pos[i].r, text: data[i].label })),
pos.map((p) => ({ x: p.x, y: p.y, r: p.r })),
{ left: 0, right: width, top: 0, bottom: height - 20 },
);
/* ---------------- interaction ---------------- */
const pick = (clientX: number, clientY: number, el: HTMLElement, mouse: boolean) => {
const b = el.getBoundingClientRect();
const px = (clientX - b.left) * (width / (b.width || width));
const py = clientY - b.top;
let best = -1;
let near = Infinity;
pos.forEach((p, i) => {
if (!p || dimmed(data[i])) return;
// Timeline: inside its lane only the distance along the time axis counts
const inLane = variant === 'timeline' && Math.abs(p.y - py) < 30;
const d = inLane ? Math.max(0, Math.abs(p.x - px) - p.r) : dist(p.x - px, p.y - py) - p.r;
// Inside a bubble the smallest one wins, it is drawn on top
const score = d <= 0 ? -1000 + p.r : d;
if (score < near) {
near = score;
best = i;
}
});
setActive(best >= 0 && (near <= (variant === 'timeline' ? 80 : 24) || !mouse) ? best : null);
};
const onKeyDown = (e: React.KeyboardEvent) => {
const at = active === null ? -1 : order.indexOf(active);
let next = at;
if (variant === 'matrix' && cols && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) next = at < 0 ? 0 : Math.max(0, Math.min(order.length - 1, at + (e.key === 'ArrowDown' ? cols : -cols)));
else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = Math.min(order.length - 1, at + 1);
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = Math.max(0, at - 1);
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = order.length - 1;
else if (e.key === 'Escape') return setActive(null);
else return;
e.preventDefault();
setActive(order[next]);
};
const total = data.reduce((a, d) => a + d.value, 0);
const rowsOf = (i: number): TooltipRow[] => {
const d = data[i];
const out: TooltipRow[] = [{ key: 'v', label: valueLabel, value: fmt(d.value) }];
if (variant === 'groups') {
if (xLabel) out.push({ key: 'x', label: xLabel, value: fx(Number(d.x ?? 0)) });
if (yLabel) out.push({ key: 'y', label: yLabel, value: fy(Number(d.y ?? 0)) });
}
if (variant === 'packed') out.push({ key: 's', label: t.total, value: new Intl.NumberFormat(intl, { style: 'percent', maximumFractionDigits: 1 }).format(d.value / (total || 1)) });
if (d.group && variant !== 'timeline') out.unshift({ key: 'g', label: d.group, value: '', color: colorOf(d), shape: 'dot' });
return out;
};
const titleOf = (i: number) => {
const d = data[i];
if (variant === 'timeline') return `${d.label} · ${xName(d)}`;
if (variant === 'matrix') return `${d.y}, ${d.x}`;
return d.label;
};
const live = active !== null && data[active] ? `${titleOf(active)}. ${rowsOf(active).filter((r) => r.value).map((r) => `${r.label} ${r.value}`).join(', ')}` : '';
const top1 = [...data].sort((a, b) => b.value - a.value)[0];
const auto = fill(t.summary, { n: String(n), label: valueLabel, min: fmt(Math.min(...data.map((d) => d.value))), max: fmt(vMax), top: top1?.label ?? '' });
const head = variant === 'matrix' ? [yLabel ?? '', xLabel ?? '', valueLabel] : variant === 'timeline' ? [t.item, t.group, t.date, valueLabel] : [t.item, ...(groups.length ? [t.group] : []), ...(variant === 'groups' ? [xLabel ?? 'x', yLabel ?? 'y'] : []), valueLabel];
const table = (
<ChartTable
caption={name}
head={head}
rows={order.map((i) => {
const d = data[i];
if (variant === 'matrix') return [String(d.y ?? ''), String(d.x ?? ''), fmt(d.value)];
if (variant === 'timeline') return [d.label, d.group ?? '', xName(d), fmt(d.value)];
return [d.label, ...(groups.length ? [d.group ?? ''] : []), ...(variant === 'groups' ? [fx(Number(d.x ?? 0)), fy(Number(d.y ?? 0))] : []), fmt(d.value)];
})}
/>
);
// Size key: two round values
const keyValues = (() => {
if (variant === 'packed') return [];
const steps = niceTicks(0, vMax, 4).ticks.filter((v) => v > 0 && v <= vMax);
const big = steps[steps.length - 1];
const small = steps.find((v) => v >= vMax * 0.15);
return big === undefined ? [] : small !== undefined && small !== big ? [small, big] : [big];
})();
const rScale = (() => {
const i = data.findIndex((d) => d.value === vMax);
return i >= 0 && pos[i] ? pos[i].r : 10;
})();
const drawOrder = data.map((_, i) => i).sort((a, b) => data[b].value - data[a].value);
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef} className="[--bub-ink2:var(--background)] dark:[--bub-ink2:var(--foreground)]">
{(variant === 'groups' || variant === 'packed') && groups.length > 1 && <PickLegend className="mb-3" active={focusGroup} onActive={setFocusGroup} items={groups.map((g, k) => ({ label: g, key: <ShapeKey shape={0} color={pairColor(k)} /> }))} />}
<div ref={wrapRef}>
<ChartSurface
label={name}
summary={summary ?? auto}
labels={labels}
live={live}
table={table}
onKeyDown={onKeyDown}
onFocus={(e) => e.target === e.currentTarget && active === null && e.currentTarget.matches(':focus-visible') && setActive(order[0] ?? null)}
onBlur={() => setActive(null)}
onPointerMove={(e) => pick(e.clientX, e.clientY, e.currentTarget, e.pointerType === 'mouse')}
onPointerDown={(e) => pick(e.clientX, e.clientY, e.currentTarget, e.pointerType === 'mouse')}
onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}
>
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
{axes}
{groupRings.map((g) => (
<g key={g.label} opacity={focusGroup !== null && focusGroup !== g.k ? 0.3 : 1} className={cn('transition-opacity duration-300', anim && 'kitch-fade')} style={delay(60)}>
<circle cx={g.x} cy={g.y} r={g.r} fill="color-mix(in oklab, var(--foreground) 3%, transparent)" stroke="var(--kitch-grid)" strokeWidth="1" strokeDasharray="3 3" />
<text x={g.x} y={g.y - g.r - 8} textAnchor="middle" className="text-[12px] font-medium" fill="var(--muted-foreground)">
{g.label}
</text>
</g>
))}
{drawOrder.map((i) => {
const d = data[i];
const p = pos[i];
if (!p) return null;
const dim = dimmed(d);
const k = order.indexOf(i);
return (
<g key={i} opacity={dim ? 0.18 : 1} className="kitch-series">
<circle cx={p.x} cy={p.y} r={p.r} fill={colorOf(d)} fillOpacity={variant === 'packed' ? 0.9 : variant === 'matrix' ? 1 : 0.78} stroke="var(--kitch-surface)" strokeWidth="2" className={cn(anim && 'kitch-pop')} style={delay(100 + (k / Math.max(1, n)) * 700)} />
{active === i && <circle cx={p.x} cy={p.y} r={p.r + 4} fill="none" stroke="var(--foreground)" strokeWidth="1.5" />}
</g>
);
})}
{/* Names inside packed bubbles and values inside matrix bubbles */}
{(variant === 'packed' || variant === 'matrix') && (
<g className="pointer-events-none">
{data.map((d, i) => {
const p = pos[i];
if (!p) return null;
const ink = variant === 'packed' ? (gi(d) === 0 ? 'var(--primary-foreground)' : gi(d) === 1 ? 'var(--background)' : 'var(--bub-ink2)') : 'var(--primary-foreground)';
const valueText = variant === 'matrix' ? tick(d.value) : fmt(d.value);
const nameFits = variant === 'packed' && textWidth(d.label, 12) + 10 <= p.r * 2 && p.r >= 20;
const valueFits = textWidth(valueText, 11) + 8 <= p.r * 2 && p.r >= 11;
if (!nameFits && !valueFits) return null;
return (
<g key={i} opacity={dimmed(d) ? 0.2 : 1} className={cn(anim && 'kitch-fade')} style={delay(700 + (order.indexOf(i) / Math.max(1, n)) * 400)}>
{nameFits && (
<text x={p.x} y={valueFits ? p.y - 7 : p.y} dy="0.34em" textAnchor="middle" className="text-[12px] font-semibold" fill={ink}>
{d.label}
</text>
)}
{valueFits && (
<text x={p.x} y={nameFits ? p.y + 8 : p.y} dy="0.34em" textAnchor="middle" className="text-[11px] font-medium tabular-nums" fill={ink} opacity={0.9}>
{valueText}
</text>
)}
</g>
);
})}
</g>
)}
{/* Direct labels beside the bubbles */}
<g className="pointer-events-none text-[11px] font-medium" fill="var(--foreground)">
{named.map((i, k) =>
dimmed(data[i]) ? null : (
<text key={i} x={spots[k].x} y={spots[k].y} dy="0.34em" textAnchor={spots[k].anchor} style={{ ...HALO, ...delay(800) }} className={anim ? 'kitch-rise' : undefined}>
{data[i].label}
</text>
),
)}
</g>
</svg>
<ChartTooltip open={open} jump={jump} x={shown !== null && pos[shown] ? pos[shown].x : 0} y={shown !== null && pos[shown] ? Math.max(0, pos[shown].y - 30) : 0} width={width} offset={shown !== null && pos[shown] ? pos[shown].r + 10 : 12} title={shown !== null && data[shown] ? titleOf(shown) : ''} rows={shown !== null && data[shown] ? rowsOf(shown).filter((r) => r.value !== '' || r.color) : []} />
</ChartSurface>
</div>
{keyValues.length > 0 && (
<div className={cn('mt-4 flex flex-wrap items-end justify-between gap-x-5 gap-y-2 text-[12px] text-muted-foreground', anim && 'kitch-rise')} style={delay(900)}>
<span className="flex flex-wrap items-end gap-x-4 gap-y-2">
<span>{fill(t.sizeKey, { label: valueLabel })}</span>
{keyValues.map((v) => {
const r = Math.max(3, round(Math.sqrt(v / vMax) * rScale));
return (
<span key={v} className="inline-flex items-end gap-1.5">
<svg width={round(2 * r + 2)} height={round(2 * r + 2)} viewBox={`0 0 ${round(2 * r + 2)} ${round(2 * r + 2)}`} aria-hidden>
<circle cx={r + 1} cy={r + 1} r={r} fill="none" stroke="var(--muted-foreground)" strokeWidth="1.25" />
</svg>
<span className="tabular-nums">{fmt(v)}</span>
</span>
);
})}
</span>
{variant === 'matrix' && phone && (
<span className="flex flex-wrap gap-x-3 gap-y-1">
{rowTotals.map((rt) => (
<span key={rt.label} className="tabular-nums">
<span className="text-foreground">{rt.label}</span> {rt.text}
</span>
))}
</span>
)}
</div>
)}
</ChartRoot>
</ChartFigure>
);
}Frequent questions
How do I install Bubble chart?
With aiw add @ai-website/bubble-chart. The command puts components/ui/bubble-chart.tsx into your project, the code is yours from then on. It brings along chart and chart-figure. It needs no further npm packages.
Which variants does Bubble chart have?
4 variants, Groups on two axes, Packed circles, Bubble timeline and Bubble matrix. You pick one with the variant prop, for example variant="packed".
Can I translate the texts of Bubble chart?
Yes. Bubble chart carries 6 texts of its own in English and German, such as “Item”, “Group” and “Total”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Bubble chart?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Bubble chart with aiw_components and install it with aiw_add @ai-website/bubble-chart. Name the variant in your prompt, for example variant="groups", and the assistant writes your content straight into the props.