Skip to content
UI Kit
Accent color

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

5 variants

Variants

Histogram

Counts per bin on round edges. The middle half of the values keeps the accent, the median stands as a line.

Open in Studio

Box plot

One box per group, with quartiles, median, whiskers at one and a half box lengths and outliers as rings.

Open in Studio

Density

Smooth curves for up to three groups on one scale, the median dashed. The pointer reads how many are faster than a point.

Open in Studio

Dot strip

Every value as a dot, packed so none hides another, the median as a line through each group.

Open in Studio

Percentiles

One spread as bands from P10 to P90 with the median, your own value placed in it and a sentence that says where it stands.

Open in Studio

Details

What Distribution chart brings along, read from the registry and the source.

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

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.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • variantoptional
    DistributionVariant

    histogrambox-plotdensitystrippercentiles

  • valuesoptional
    number[]

    histogram, percentiles: the raw values. Other variants: use groups.

  • groupsoptional
    DistributionGroup[]

    box-plot, density, strip: one entry per group

  • unitLabeloptional
    string

    Name of the measure, shown at the axis

  • formatoptional
    Intl.NumberFormatOptions
  • binsoptional
    number

    histogram: 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

  • lowerIsBetteroptional
    boolean

    For 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
shareLabelEnglish ShareGerman Anteil
outerEnglish Outside the middle halfGerman Außerhalb der mittleren Hälfte
countEnglish CountGerman Anzahl
rangeEnglish {a} to {b}German {a} bis {b}
medianEnglish MedianGerman Median
q1English Lower quartileGerman Unteres Quartil
q3English Upper quartileGerman Oberes Quartil
minEnglish LowestGerman Kleinster
maxEnglish HighestGerman Größter
outliersEnglish OutliersGerman Ausreißer
nEnglish {n} valuesGerman {n} Werte
groupEnglish GroupGerman Gruppe
pEnglish P{n}German P{n}
betterEnglish Better than {share} of allGerman Besser als {share} aller
worseEnglish Behind {share} of allGerman Hinter {share} aller
bandMidEnglish Middle halfGerman Mittlere Hälfte
bandWideEnglish 80 of 100German 80 von 100
valueEnglish ValueGerman 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
components/ui/distribution-chart.tsx
'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.

Often searched

Jump to

Copied to the clipboard