Distribution chart
How values spread, not only their average. As a histogram, box plots, density curves, a dot strip where no dot hides another, or percentile bands with your own value placed in them.
One of 4 components in Statistical charts
Variants
Details
What Distribution chart brings along, read from the registry and the source.
- Category
- Statistical charts
- Area
- Elements
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/distribution-chart.tsx
- Brings along
- utilskitchartchart-figure
- npm packages
- None
- Languages
- English and German, 18 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The DistributionChartProps interface from components/ui/distribution-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.
variantoptionalDistributionVarianthistogrambox-plotdensitystrippercentiles
valuesoptionalnumber[]histogram, percentiles: the raw values. Other variants: use groups.
groupsoptionalDistributionGroup[]box-plot, density, strip: one entry per group
unitLabeloptionalstringName of the measure, shown at the axis
formatoptionalIntl.NumberFormatOptionsbinsoptionalnumberhistogram: number of bins. Default: about 12, on round edges.
markeroptional{ value: number; label: string }percentiles: a value to place in the spread, e.g. your own result
lowerIsBetteroptionalbooleanFor measures where lower is better (load time): the percentile sentence counts from the top
Built-in texts
Distribution chart writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 18 texts
| Key | English | German |
|---|---|---|
| shareLabel | English Share | German Anteil |
| outer | English Outside the middle half | German Außerhalb der mittleren Hälfte |
| count | English Count | German Anzahl |
| range | English {a} to {b} | German {a} bis {b} |
| median | English Median | German Median |
| q1 | English Lower quartile | German Unteres Quartil |
| q3 | English Upper quartile | German Oberes Quartil |
| min | English Lowest | German Kleinster |
| max | English Highest | German Größter |
| outliers | English Outliers | German Ausreißer |
| n | English {n} values | German {n} Werte |
| group | English Group | German Gruppe |
| p | English P{n} | German P{n} |
| better | English Better than {share} of all | German Besser als {share} aller |
| worse | English Behind {share} of all | German Hinter {share} aller |
| bandMid | English Middle half | German Mittlere Hälfte |
| bandWide | English 80 of 100 | German 80 von 100 |
| value | English Value | German Wert |
Accessibility
Read from the source of the component.
- Distribution 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, 426 lines
'use client';
import { useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { ChartRoot, ChartSurface, ChartTable, ChartTooltip, Swatch, barPath, fill, linePath, niceTicks, numberFormatter, textWidth, tickFormatter, useEntrance, useIntlLocale, useShown, useWidth, type ChartA11y, type TooltipRow } from '@/components/ui/chart';
import { ChartFigure, HALO, PickLegend, PlotAxes, linear, pairColor, type ChartFigureProps } from '@/components/ui/chart-figure';
/*
* How values spread, not only their average.
* histogram counts per bin, the median marked
* box-plot quartiles, median, whiskers and outliers per group
* density smooth curves per group on one scale (up to three)
* strip every value as a dot, packed so none hides another
* percentiles one spread as bands from P10 to P90, a marked value placed in it
*
* The statistics come from the raw values: quantiles by linear
* interpolation, whiskers at 1.5 times the box, density with a Gaussian
* kernel and Silverman's bandwidth.
*/
export type DistributionVariant = 'histogram' | 'box-plot' | 'density' | 'strip' | 'percentiles';
export interface DistributionGroup {
label: string;
values: number[];
}
export interface DistributionChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
/** Accessible name of the chart. Default: the title. */
label?: string;
variant?: DistributionVariant;
/** histogram, percentiles: the raw values. Other variants: use groups. */
values?: number[];
/** box-plot, density, strip: one entry per group */
groups?: DistributionGroup[];
/** Name of the measure, shown at the axis */
unitLabel?: string;
format?: Intl.NumberFormatOptions;
/** histogram: number of bins. Default: about 12, on round edges. */
bins?: number;
/** percentiles: a value to place in the spread, e.g. your own result */
marker?: { value: number; label: string };
/** For measures where lower is better (load time): the percentile sentence counts from the top */
lowerIsBetter?: boolean;
className?: string;
}
const TEXTS = {
en: { shareLabel: 'Share', outer: 'Outside the middle half', count: 'Count', range: '{a} to {b}', median: 'Median', q1: 'Lower quartile', q3: 'Upper quartile', min: 'Lowest', max: 'Highest', outliers: 'Outliers', n: '{n} values', group: 'Group', p: 'P{n}', better: 'Better than {share} of all', worse: 'Behind {share} of all', bandMid: 'Middle half', bandWide: '80 of 100', value: 'Value' },
de: { shareLabel: 'Anteil', outer: 'Außerhalb der mittleren Hälfte', count: 'Anzahl', range: '{a} bis {b}', median: 'Median', q1: 'Unteres Quartil', q3: 'Oberes Quartil', min: 'Kleinster', max: 'Größter', outliers: 'Ausreißer', n: '{n} Werte', group: 'Gruppe', p: 'P{n}', better: 'Besser als {share} aller', worse: 'Hinter {share} aller', bandMid: 'Mittlere Hälfte', bandWide: '80 von 100', value: 'Wert' },
};
/** Quantile by linear interpolation of a sorted array */
export function quantile(sorted: number[], q: number) {
if (!sorted.length) return 0;
const pos = (sorted.length - 1) * q;
const lo = Math.floor(pos);
const hi = Math.ceil(pos);
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
}
function stats(values: number[]) {
const s = [...values].sort((a, b) => a - b);
const q1 = quantile(s, 0.25);
const q3 = quantile(s, 0.75);
const iqr = q3 - q1;
const lo = s.find((v) => v >= q1 - 1.5 * iqr) ?? s[0];
const hi = [...s].reverse().find((v) => v <= q3 + 1.5 * iqr) ?? s[s.length - 1];
return { s, q1, q3, median: quantile(s, 0.5), lo, hi, out: s.filter((v) => v < lo || v > hi), min: s[0], max: s[s.length - 1] };
}
/** Gaussian kernel density at the given points */
function density(values: number[], at: number[]) {
const n = values.length || 1;
const mean = values.reduce((a, v) => a + v, 0) / n;
const sd = Math.sqrt(values.reduce((a, v) => a + (v - mean) ** 2, 0) / n) || 1;
const s = [...values].sort((a, b) => a - b);
const iqr = quantile(s, 0.75) - quantile(s, 0.25);
const h = 0.9 * Math.min(sd, iqr / 1.34 || sd) * n ** -0.2;
return at.map((x) => values.reduce((a, v) => a + Math.exp(-0.5 * ((x - v) / h) ** 2), 0) / (n * h * Math.sqrt(2 * Math.PI)));
}
/**
* The spread of values: a histogram, box plots, density curves, a dot strip or percentile bands.
* <DistributionChart variant="box-plot" groups={[{ label: 'Mon', values: [12, 18, …] }]} unitLabel="Minutes" />
*/
export function DistributionChart(props: DistributionChartProps) {
const { variant = 'histogram', values = [], groups = [], unitLabel = '', format, bins: binsProp, marker, lowerIsBetter, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
const t = useTexts(TEXTS, undefined);
const intl = useIntlLocale();
const fmt = numberFormatter(intl, format);
const tick = tickFormatter(intl, format);
const pct = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 0 });
const [wrapRef, width] = useWidth<HTMLDivElement>(720);
const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
const [active, setActive] = useState<number | null>(null);
const { shown, open, jump } = useShown(active);
const [focus, setFocus] = useState<number | null>(null);
const name = label ?? title ?? unitLabel;
const phone = width < 520;
const d = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);
const keys = (n: number) => (e: React.KeyboardEvent) => {
const cur = active ?? -1;
let next: number | null = cur;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = Math.min(n - 1, cur + 1);
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = Math.max(0, cur - 1);
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = n - 1;
else if (e.key === 'Escape') next = null;
else return;
e.preventDefault();
setActive(next);
};
const focusIn = (e: React.FocusEvent) => e.target === e.currentTarget && active === null && (e.currentTarget as HTMLElement).matches(':focus-visible') && setActive(0);
/* ---------------- histogram ---------------- */
if (variant === 'histogram') {
const st = stats(values);
const nice = niceTicks(st.min, st.max, binsProp ?? (phone ? 8 : 12));
const step = nice.ticks[1] - nice.ticks[0] || 1;
const edges = nice.ticks;
const counts = edges.slice(0, -1).map((a, i) => values.filter((v) => v >= a && (i === edges.length - 2 ? v <= edges[i + 1] : v < edges[i + 1])).length);
const ny = niceTicks(0, Math.max(...counts, 1), 4);
const height = phone ? 260 : 300;
const left = Math.max(...ny.ticks.map((v) => textWidth(String(v), 11)), 14) + 14;
const right = width - 8;
const top = 30;
const bottom = height - 42;
const X = linear(nice.min, nice.max, left, right);
const Y = linear(0, ny.max, bottom, top);
const n = counts.length;
const bw = X(nice.min + step) - X(nice.min);
const pick = (clientX: number, el: HTMLElement) => {
const r = el.getBoundingClientRect();
const px = (clientX - r.left) * (width / (r.width || width));
setActive(Math.max(0, Math.min(n - 1, Math.floor((px - left) / bw))));
};
const rows = (i: number): TooltipRow[] => [
{ key: 'c', label: t.count, value: String(counts[i]), color: 'var(--primary)', shape: 'rect' },
{ key: 's', label: t.shareLabel, value: pct(counts[i] / Math.max(1, values.length)) },
];
const binTitle = (i: number) => fill(t.range, { a: fmt(edges[i]), b: fmt(edges[i + 1]) });
const every = Math.max(1, Math.ceil((textWidth(tick(nice.max), 11) + 12) / bw));
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
<div ref={wrapRef}>
<ChartSurface label={name} summary={summary ?? `${fill(t.n, { n: String(values.length) })}. ${t.median} ${fmt(st.median)}.`} labels={labels} live={active !== null ? `${binTitle(active)}. ${t.count} ${counts[active]}` : ''} table={<ChartTable caption={name} head={[unitLabel, t.count]} rows={counts.map((c, i) => [binTitle(i), String(c)])} />} onKeyDown={keys(n)} onFocus={focusIn} onBlur={() => setActive(null)} onPointerMove={(e) => pick(e.clientX, e.currentTarget)} onPointerDown={(e) => pick(e.clientX, e.currentTarget)} onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}>
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
<PlotAxes left={left} right={right} top={top} bottom={bottom} height={height} yTicks={ny.ticks.map((v) => ({ at: Y(v), label: String(v), strong: v === 0 }))} xTicks={edges.map((v, i) => ({ at: X(v), label: i % every ? '' : tick(v) }))} yTitle={t.count} xTitle={unitLabel} />
{counts.map((c, i) => {
const x0 = X(edges[i]) + 1;
const h = Y(0) - Y(c);
const inMid = edges[i + 1] > st.q1 && edges[i] < st.q3;
return c > 0 ? (
<path key={i} d={barPath(x0, Y(c), Math.max(1, bw - 2), h, 3, 'up')} fill={inMid ? 'var(--primary)' : 'color-mix(in oklab, var(--primary) 50%, var(--card))'} opacity={active !== null && active !== i ? 0.5 : 1} className={cn('transition-opacity duration-200', anim && 'kitch-grow-y')} style={d(80 + i * 45)} />
) : null;
})}
{/* Median */}
<g className={anim ? 'kitch-fade' : undefined} style={d(800)}>
<line x1={X(st.median)} x2={X(st.median)} y1={top - 10} y2={bottom} stroke="var(--foreground)" strokeWidth="1.5" strokeDasharray="4 3" />
<text x={X(st.median) + 6} y={top - 12} className="text-[11px] font-medium tabular-nums" fill="var(--foreground)" style={HALO}>
{`${t.median} ${fmt(st.median)}`}
</text>
</g>
</svg>
<ChartTooltip open={open} jump={jump} x={shown !== null ? X(edges[shown]) + bw / 2 : 0} y={top} width={width} offset={bw / 2 + 6} title={shown !== null ? binTitle(shown) : ''} rows={shown !== null ? rows(shown) : []} />
</ChartSurface>
<ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-[12px] text-muted-foreground">
<li className="flex items-center gap-1.5"><Swatch color="var(--primary)" />{t.bandMid}</li>
<li className="flex items-center gap-1.5"><Swatch color={'color-mix(in oklab, var(--primary) 50%, var(--card))'} />{t.outer}</li>
</ul>
</div>
</ChartRoot>
</ChartFigure>
);
}
/* ---------------- percentiles ---------------- */
if (variant === 'percentiles') {
const s = [...values].sort((a, b) => a - b);
const P = [10, 25, 50, 75, 90].map((q) => quantile(s, q / 100));
const nice = niceTicks(s[0], s[s.length - 1], phone ? 4 : 6);
const X = (v: number) => `${round(((v - nice.min) / (nice.max - nice.min || 1)) * 100, 2)}%`;
const rank = marker ? s.filter((v) => (lowerIsBetter ? v > marker.value : v < marker.value)).length / Math.max(1, s.length) : 0;
const items = [
{ label: t.p.replace('{n}', '10'), v: P[0] },
{ label: t.p.replace('{n}', '25'), v: P[1] },
{ label: t.median, v: P[2] },
{ label: t.p.replace('{n}', '75'), v: P[3] },
{ label: t.p.replace('{n}', '90'), v: P[4] },
];
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
<div ref={wrapRef}>
<ChartSurface label={name} summary={summary ?? items.map((i) => `${i.label} ${fmt(i.v)}`).join(', ')} labels={labels} live={active !== null ? `${items[active].label} ${fmt(items[active].v)}` : ''} table={<ChartTable caption={name} head={[t.value, unitLabel]} rows={[...items.map((i) => [i.label, fmt(i.v)]), ...(marker ? [[marker.label, fmt(marker.value)]] : [])]} />} onKeyDown={keys(items.length)} onFocus={focusIn} onBlur={() => setActive(null)}>
{marker && (
<div className="relative mb-2 h-14">
<div className={cn('absolute bottom-0 flex -translate-x-1/2 flex-col items-center', anim && 'kitch-rise')} style={{ left: X(marker.value), ...d(900) }}>
<span className="whitespace-nowrap rounded-md bg-foreground px-2 py-1 text-[12px] font-semibold tabular-nums text-background shadow-[var(--shadow-sm)]">{`${marker.label} · ${fmt(marker.value)}`}</span>
<span aria-hidden className="h-3 w-px bg-foreground" />
</div>
</div>
)}
<div className="relative h-14 overflow-hidden rounded-lg bg-[color-mix(in_oklab,var(--foreground)_5%,var(--card))]">
<div className={cn('absolute inset-y-0', anim && 'kitch-grow-x')} style={{ left: X(P[0]), width: `calc(${X(P[4])} - ${X(P[0])})`, background: 'color-mix(in oklab, var(--primary) 22%, var(--card))', ...d(100) }} />
<div className={cn('absolute inset-y-0', anim && 'kitch-grow-x')} style={{ left: X(P[1]), width: `calc(${X(P[3])} - ${X(P[1])})`, background: 'color-mix(in oklab, var(--primary) 48%, var(--card))', ...d(300) }} />
<div className="absolute inset-y-0 w-[3px] -translate-x-1/2 bg-primary" style={{ left: X(P[2]) }} />
{marker && <div aria-hidden className="absolute inset-y-0 w-[2px] -translate-x-1/2 bg-foreground" style={{ left: X(marker.value) }} />}
{items.map((it, i) => (
<button
key={it.label}
type="button"
tabIndex={-1}
aria-hidden
onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(i)}
onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}
onClick={() => setActive(active === i ? null : i)}
className="absolute inset-y-0 w-6 -translate-x-1/2"
style={{ left: X(it.v) }}
/>
))}
</div>
<div aria-hidden className="relative mt-2 h-10 text-[11px] tabular-nums">
{items.map((it, i) => (
<span key={it.label} className={cn('absolute top-0 flex -translate-x-1/2 flex-col items-center whitespace-nowrap transition-colors', active === i ? 'text-foreground' : 'text-muted-foreground', phone && (i === 1 || i === 3) && 'hidden')} style={{ left: X(it.v) }}>
<span className="font-medium">{it.label}</span>
<span className={cn(active === i && 'font-semibold')}>{fmt(it.v)}</span>
</span>
))}
</div>
</ChartSurface>
<div className="mt-4 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 border-t border-border pt-4">
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-[12px] text-muted-foreground">
<li className="flex items-center gap-1.5"><span aria-hidden className="size-2.5 rounded-[3px]" style={{ background: 'color-mix(in oklab, var(--primary) 48%, var(--card))' }} />{`${t.bandMid}, ${fill(t.range, { a: 'P25', b: 'P75' })}`}</li>
<li className="flex items-center gap-1.5"><span aria-hidden className="size-2.5 rounded-[3px]" style={{ background: 'color-mix(in oklab, var(--primary) 22%, var(--card))' }} />{`${t.bandWide}, ${fill(t.range, { a: 'P10', b: 'P90' })}`}</li>
</ul>
{marker && <p className="text-[13px] font-medium text-foreground">{fill(rank >= 0.5 ? t.better : t.worse, { share: pct(rank >= 0.5 ? rank : 1 - rank) })}</p>}
</div>
</div>
</ChartRoot>
</ChartFigure>
);
}
/* ---------------- groups: box-plot, density, strip ---------------- */
const G = variant === 'density' ? groups.slice(0, 3) : groups;
const all = G.flatMap((g) => g.values);
const nice = niceTicks(Math.min(...all), Math.max(...all), phone ? 4 : 6);
const STS = G.map((g) => stats(g.values));
if (variant === 'density') {
const height = phone ? 260 : 300;
const left = 14;
const right = width - 10;
const top = 16;
const bottom = height - 40;
const X = linear(nice.min, nice.max, left, right);
const steps = 80;
const xs = Array.from({ length: steps + 1 }, (_, i) => nice.min + ((nice.max - nice.min) * i) / steps);
const dens = G.map((g) => density(g.values, xs));
const dMax = Math.max(...dens.flat(), 1e-9);
const Y = linear(0, dMax * 1.08, bottom, top);
const [hover, setHover] = [active, setActive];
const pick = (clientX: number, el: HTMLElement) => {
const r = el.getBoundingClientRect();
const px = (clientX - r.left) * (width / (r.width || width));
setHover(Math.max(0, Math.min(steps, Math.round(((px - left) / (right - left)) * steps))));
};
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
<PickLegend className="mb-3" active={focus} onActive={setFocus} items={G.map((g, i) => ({ label: `${g.label} · ${t.median} ${fmt(STS[i].median)}`, key: <Swatch color={pairColor(i)} /> }))} />
<div ref={wrapRef}>
<ChartSurface label={name} summary={summary ?? G.map((g, i) => `${g.label}, ${t.median} ${fmt(STS[i].median)}`).join('. ')} labels={labels} live={hover !== null ? `${fmt(xs[hover])}` : ''} table={<ChartTable caption={name} head={[t.group, t.q1, t.median, t.q3]} rows={G.map((g, i) => [g.label, fmt(STS[i].q1), fmt(STS[i].median), fmt(STS[i].q3)])} />} onKeyDown={keys(steps + 1)} onFocus={focusIn} onBlur={() => setActive(null)} onPointerMove={(e) => pick(e.clientX, e.currentTarget)} onPointerDown={(e) => pick(e.clientX, e.currentTarget)} onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}>
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
<PlotAxes left={left} right={right} top={top} bottom={bottom} height={height} yTicks={[{ at: bottom, label: '', strong: true }]} xTicks={nice.ticks.map((v) => ({ at: X(v), label: tick(v) }))} xTitle={unitLabel} xGrid />
{[...G.keys()].reverse().map((i) => {
const pts = xs.map((x, k) => [X(x), Y(dens[i][k])] as [number, number]);
const line = linePath(pts);
return (
<g key={G[i].label} className="kitch-series" opacity={focus !== null && focus !== i ? 0.15 : 1}>
<path d={`${line}L${X(nice.max)} ${bottom}L${X(nice.min)} ${bottom}Z`} fill={pairColor(i)} fillOpacity="0.12" className={anim ? 'kitch-fade' : undefined} style={d(400 + i * 200)} />
<path d={line} pathLength={1} fill="none" stroke={pairColor(i)} strokeWidth="2" className={anim ? 'kitch-draw' : undefined} style={d(100 + i * 200)} />
<line x1={X(STS[i].median)} x2={X(STS[i].median)} y1={Y(density(G[i].values, [STS[i].median])[0])} y2={bottom} stroke={pairColor(i)} strokeWidth="1.5" strokeDasharray="3 3" />
</g>
);
})}
{hover !== null && (
<g>
<line x1={X(xs[hover])} x2={X(xs[hover])} y1={top} y2={bottom} stroke="var(--kitch-axis)" strokeWidth="1" />
{G.map((g, i) => (
<circle key={g.label} cx={X(xs[hover])} cy={Y(dens[i][hover])} r={4} fill={pairColor(i)} stroke="var(--kitch-surface)" strokeWidth="2" />
))}
</g>
)}
</svg>
<ChartTooltip open={open} jump={jump} x={shown !== null ? X(xs[shown]) : 0} y={top} width={width} title={shown !== null ? fmt(xs[shown]) : ''} rows={shown !== null ? G.map((g, i) => ({ key: g.label, label: g.label, value: pct(g.values.filter((v) => v <= xs[shown]).length / Math.max(1, g.values.length)), color: pairColor(i), shape: 'line' as const })) : []} footer={undefined} />
</ChartSurface>
</div>
</ChartRoot>
</ChartFigure>
);
}
// box-plot and strip: one row per group
const rowH = variant === 'strip' ? (phone ? 64 : 72) : 52;
const labelW = phone ? 0 : Math.min(150, Math.max(...G.map((g) => textWidth(g.label, 12))) + 16);
const left = labelW + 10;
const right = width - 10;
const top = phone ? 6 : 4;
const groupTop = (i: number) => top + i * (rowH + (phone ? 22 : 0)) + (phone ? 20 : 0);
const height = groupTop(G.length) + 28;
const X = linear(nice.min, nice.max, left, right);
const rows = (i: number): TooltipRow[] => {
const s = STS[i];
return [
{ key: 'm', label: t.median, value: fmt(s.median) },
{ key: 'q', label: `${t.q1} / ${t.q3}`, value: `${fmt(s.q1)} / ${fmt(s.q3)}` },
{ key: 'r', label: `${t.min} / ${t.max}`, value: `${fmt(s.min)} / ${fmt(s.max)}` },
{ key: 'n', label: t.count, value: String(G[i].values.length) },
];
};
// Strip: pack dots in rows so none covers another (simple beeswarm)
const swarm = (vals: number[], cy: number, r: number) => {
const placed: { x: number; y: number; v: number }[] = [];
[...vals].sort((a, b) => a - b).forEach((v) => {
const x = X(v);
for (let k = 0; k < 40; k++) {
const off = (k % 2 ? 1 : -1) * Math.ceil(k / 2) * (r * 2 + 1);
const y = cy + off;
if (Math.abs(off) > rowH / 2 - r) continue;
if (placed.every((p) => (p.x - x) ** 2 + (p.y - y) ** 2 >= (2 * r + 0.8) ** 2)) {
placed.push({ x, y, v });
return;
}
}
placed.push({ x, y: cy, v });
});
return placed;
};
const pick = (clientY: number, el: HTMLElement) => {
const r = el.getBoundingClientRect();
const y = clientY - r.top;
const i = G.findIndex((_, k) => y >= groupTop(k) - (phone ? 20 : 0) && y < groupTop(k) + rowH);
setActive(i >= 0 ? i : null);
};
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
<div ref={wrapRef}>
<ChartSurface label={name} summary={summary ?? G.map((g, i) => `${g.label}, ${t.median} ${fmt(STS[i].median)}`).join('. ')} labels={labels} live={active !== null && G[active] ? `${G[active].label}. ${rows(active).map((r) => `${r.label} ${r.value}`).join(', ')}` : ''} table={<ChartTable caption={name} head={[t.group, t.min, t.q1, t.median, t.q3, t.max]} rows={G.map((g, i) => [g.label, fmt(STS[i].min), fmt(STS[i].q1), fmt(STS[i].median), fmt(STS[i].q3), fmt(STS[i].max)])} />} onKeyDown={keys(G.length)} onFocus={focusIn} onBlur={() => setActive(null)} onPointerMove={(e) => pick(e.clientY, e.currentTarget)} onPointerDown={(e) => pick(e.clientY, e.currentTarget)} onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}>
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
{nice.ticks.map((v) => (
<g key={v}>
<line x1={X(v)} x2={X(v)} y1={top} y2={height - 22} stroke="var(--kitch-grid)" strokeWidth="1" shapeRendering="crispEdges" />
<text x={X(v)} y={height - 6} textAnchor="middle" className="text-[11px] tabular-nums" fill="var(--muted-foreground)">{tick(v)}</text>
</g>
))}
{G.map((g, i) => {
const s = STS[i];
const y0 = groupTop(i);
const cy = y0 + rowH / 2;
const on = active === i;
const dim = active !== null && !on;
const bh = 22;
return (
<g key={g.label} className="transition-opacity duration-200" opacity={dim ? 0.45 : 1}>
{on && <rect x={0} y={y0 - (phone ? 20 : 0)} width={width} height={rowH + (phone ? 20 : 0)} rx="8" fill="var(--foreground)" opacity="0.045" />}
{phone ? (
<text x={left} y={y0 - 6} className="text-[12px] font-medium" fill="var(--foreground)">{g.label}</text>
) : (
<text x={0} y={cy} dy="0.34em" className="text-[12px] font-medium" fill="var(--foreground)">{g.label}</text>
)}
{variant === 'box-plot' ? (
<g className={anim ? 'kitch-grow-x' : undefined} style={{ ...d(100 + i * 90), transformOrigin: `${X(s.median)}px ${cy}px`, transformBox: 'view-box' }}>
<line x1={X(s.lo)} x2={X(s.q1)} y1={cy} y2={cy} stroke="var(--muted-foreground)" strokeWidth="1.5" />
<line x1={X(s.q3)} x2={X(s.hi)} y1={cy} y2={cy} stroke="var(--muted-foreground)" strokeWidth="1.5" />
<line x1={X(s.lo)} x2={X(s.lo)} y1={cy - 7} y2={cy + 7} stroke="var(--muted-foreground)" strokeWidth="1.5" />
<line x1={X(s.hi)} x2={X(s.hi)} y1={cy - 7} y2={cy + 7} stroke="var(--muted-foreground)" strokeWidth="1.5" />
<rect x={X(s.q1)} y={cy - bh / 2} width={Math.max(2, X(s.q3) - X(s.q1))} height={bh} rx="4" fill="color-mix(in oklab, var(--primary) 26%, var(--card))" stroke="var(--primary)" strokeWidth="1.5" />
<line x1={X(s.median)} x2={X(s.median)} y1={cy - bh / 2} y2={cy + bh / 2} stroke="var(--foreground)" strokeWidth="2.5" />
{s.out.map((v, k) => (
<circle key={k} cx={X(v)} cy={cy} r={3.5} fill="var(--kitch-surface)" stroke="var(--muted-foreground)" strokeWidth="1.5" />
))}
</g>
) : (
<g>
{swarm(g.values, cy, phone ? 3 : 3.5).map((p, k) => (
<circle key={k} cx={p.x} cy={round(p.y)} r={phone ? 3 : 3.5} fill={p.v >= s.q1 && p.v <= s.q3 ? 'var(--primary)' : 'color-mix(in oklab, var(--primary) 50%, var(--card))'} className={anim ? 'kitch-pop' : undefined} style={d(80 + i * 120 + k * 6)} />
))}
<line x1={X(s.median)} x2={X(s.median)} y1={y0 + 2} y2={y0 + rowH - 2} stroke="var(--foreground)" strokeWidth="2" strokeLinecap="round" />
</g>
)}
{!phone && on && (
<text x={X(s.median)} y={y0 - 2} textAnchor="middle" className="text-[11px] font-semibold tabular-nums" fill="var(--foreground)" style={HALO}>{fmt(s.median)}</text>
)}
</g>
);
})}
</svg>
<ChartTooltip open={open} jump={jump} x={shown !== null && STS[shown] ? X(STS[shown].q3) : 0} y={shown !== null ? groupTop(shown) : 0} width={width} offset={16} title={shown !== null && G[shown] ? G[shown].label : ''} rows={shown !== null && STS[shown] ? rows(shown) : []} />
</ChartSurface>
<ul className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px] text-muted-foreground">
{variant === 'box-plot' ? (
<>
<li className="flex items-center gap-1.5"><span aria-hidden className="h-2.5 w-4 rounded-[3px] border-[1.5px] border-primary" style={{ background: 'color-mix(in oklab, var(--primary) 26%, var(--card))' }} />{t.bandMid}</li>
<li className="flex items-center gap-1.5"><span aria-hidden className="h-3 w-[2.5px] bg-foreground" />{t.median}</li>
<li className="flex items-center gap-1.5"><span aria-hidden className="size-2.5 rounded-full border-[1.5px] border-muted-foreground" />{t.outliers}</li>
</>
) : (
<>
<li className="flex items-center gap-1.5"><Swatch shape="dot" color="var(--primary)" />{t.bandMid}</li>
<li className="flex items-center gap-1.5"><Swatch shape="dot" color={'color-mix(in oklab, var(--primary) 50%, var(--card))'} />{t.outer}</li>
<li className="flex items-center gap-1.5"><span aria-hidden className="h-3 w-[2px] bg-foreground" />{t.median}</li>
</>
)}
{unitLabel && <li className="ms-auto">{unitLabel}</li>}
</ul>
</div>
</ChartRoot>
</ChartFigure>
);
}Frequent questions
How do I install Distribution chart?
With aiw add @ai-website/distribution-chart. The command puts components/ui/distribution-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 Distribution chart have?
5 variants, Histogram, Box plot, Density, Dot strip and Percentiles. You pick one with the variant prop, for example variant="box-plot".
Can I translate the texts of Distribution chart?
Yes. Distribution chart carries 18 texts of its own in English and German, such as “Share”, “Outside the middle half” and “Count”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Distribution chart?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Distribution chart with aiw_components and install it with aiw_add @ai-website/distribution-chart. Name the variant in your prompt, for example variant="histogram", and the assistant writes your content straight into the props.
Can I adjust Distribution chart without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching DistributionChart code to copy.