Beeswarm
Jittered strip / beeswarm chart for comparing a subject against a cohort on a single metric. Dots pack deterministically along the cross-axis; highlighted values stay anchored to their true position and can carry direct or callout labels. Cursor tooltip reveals each dot's identity. Hero fixture: Bukayo Saka's npxG-per-match arc 2020-21 → 2024-25 against the 170-ish-deep Premier League cohort of players with ≥20 shots.
Canonical behaviors.
Thresholded colouring + median reference line. Matches the Data'Scout aesthetic.
Multiple highlights in a dense cluster — callouts keep names readable without hiding the underlying distribution.
Dot radius encodes a secondary metric (here: shot volume). Uses a sqrt scale so area is perceptually proportional.
When a cohort gets too dense for labels, shrink dot radius and padding first, then drop labels entirely before reaching for a different chart.
No values, no fake dots.
Shared concerns.
Choose Beeswarm
Use a Beeswarm when the story is "where does this player sit in the full league distribution on one metric?". Not for multi-dimensional profiles — reach for RadarChart or PizzaChart instead.
Packing
Packing is deterministic: same data in, same positions out. No force simulation means the bundle is byte-stable and the dots never drift between renders. The trade-off is a slightly tighter "bowtie" shape than a force simulation produces.
Highlights vs. Labels
Highlights are for subject dots you want to label or style differently. Cursor tooltips cover the rest — don't try to label every dot.
Accessibility
The SVG carries an aria-label describing the metric and value count. Each dot has a cursor tooltip with its player label and value.
Composition
Beeswarm is compose-safe: pass a custom layout to shrink the viewBox
to your grid cell, and background for seamless themes.
Large-N Guidance
Once a single group pushes past roughly 150-200 dots, reduce dotRadius
and dotPadding, then drop labels to highlights only or
labelStrategy="none". If the value distribution itself matters more
than individual subjects, switch to a distribution-style chart instead.
Best-practice examples.
Minimal usage
A Beeswarm needs at least one group of values and a metric label. Each value is a dot; dots pack deterministically along the cross-axis.
import { Beeswarm } from "@withqwerty/campos-react";
export function XGPerMatchSwarm({ players }) {
return (
<Beeswarm
groups={[{ id: "23-24", label: "23-24", values: players }]}
metric={{ label: "npxG / match" }}
/>
);
}
Vertical small-multiples with highlights
Multi-group vertical layout sharing a numeric Y axis. Subject dots get labelled; category colouring tags each player's competition.
<Beeswarm
orientation="vertical"
groups={seasonGroups}
metric={{ label: "npxG / match", domain: [0, 1] }}
populationColor={{
mode: "byCategory",
colors: { "Premier League": "#b91c1c" },
}}
highlightDefaults={{ color: "#f5f3ff", radius: 6 }}
/>
Quantile colouring + reference lines
Assign colours by threshold bands. Reference lines mark the median or any other axis value.
<Beeswarm
groups={[{ id: "pl", label: "24-25", values }]}
metric={{ label: "npxG / match" }}
populationColor={{
mode: "byQuantile",
bands: [
{ threshold: 0.1, color: "#ef4444", label: "bottom 20%" },
{ threshold: 0.22, color: "#f59e0b" },
{ threshold: 0.4, color: "#84cc16" },
],
aboveColor: "#22c55e",
aboveLabel: "top 20%",
}}
referenceLines={[{ value: 0.2, label: "median" }]}
/>
Callout labels
Use labelStrategy="callout" when many highlights cluster together. A thin line connects the label to the dot.
<Beeswarm
groups={[{ id: "arsenal", label: "23-24 Arsenal", values }]}
metric={{ label: "npxG / match" }}
populationColor={{ mode: "uniform", color: "#9ca3af" }}
labelStrategy="callout"
/>
Size-by-field
Encode a second metric via dot area using a sqrt scale. Good for goals counts, minutes, sample sizes.
<Beeswarm
groups={[{ id: "g", label: "G", values }]}
metric={{ label: "npxG / match" }}
sizeField={{ range: [1.5, 6], legendTicks: [20, 80, 140] }}
/>
Public surface.
| Prop | Type | Default | Description |
|---|---|---|---|
groups | readonly BeeswarmGroupInput[] | required | One or more named groups that share the numeric scale. |
metric | BeeswarmMetric | required | Axis label, optional fixed domain, tick count, and formatter. |
orientation | "horizontal" | "vertical" | "horizontal" | Axis orientation. Vertical inverts Y so higher values sit at the top of each column. |
populationColor | { mode: "uniform" | "byCategory" | "byQuantile"; ... } | uniform grey | Colour encoding for non-highlighted dots. Category mode keys off value.category; quantile mode thresholds match value. |
highlightDefaults | { color; radius; stroke; strokeWidth } | #f97316 / r=5 / #ffffff / 1 | Default appearance for any value whose `highlight` is set. Per-value highlight can override. |
labelStrategy | "direct" | "callout" | "none" | "direct" | Where and how highlight labels render. Callout adds a connector line; none hides labels entirely. |
sizeField | { range; domain?; legendTicks?; legendLabel? } | — | Encode dot radius from value.size using a sqrt scale. Pass legendTicks for a size legend. |
referenceLines | readonly BeeswarmReferenceLineInput[] | — | One or more lines drawn across all groups at a given axis value. |
dotRadius | number | 2.4 | Base radius for population dots in viewBox units. |
dotPadding | number | 0.4 | Gap between packed dots during the bin-jitter packing step. |
renderTooltip | (dot) => ReactNode | label + formatted value | Override the cursor tooltip content per dot. |
layout | BeeswarmLayoutInput | — | ViewBox size, group/axis/label/legend sizing overrides when the defaults do not fit your composite. |
Create a React component using Campos Beeswarm. Import Beeswarm from @withqwerty/campos-react. Start with a single group, one metric, minimal props. Add orientation="vertical" for season-over-season small multiples. Mark subject players with highlight. Only add quantile or size encoding if the use case clearly needs it.