Beeswarm Chart
betaOne dot per value along a single axis, stacked where values crowd, with a themed tooltip, legend, and states.
Preview
one
grouped
data
panels
currency
size
loads, then draws
Loading chart
loading
Loading chart
empty
No pages yet
error
Load times could not load.
Load times could not load.
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/beeswarm-chart
Use it for
- Showing how values spread out, like load time per page or deal size per customer, when each dot is one thing.
- Pass data, the valueKey that places each dot, and a config.
- Pass labelKey to name each dot in the tooltip.
- Change data, as from tabs, and each dot moves to its new value. Dots are matched by labelKey and group, or by their order without labelKey. New dots grow in, and gone dots shrink away.
- To move dots between tabs, keep one chart and change its data. A chart inside each tab panel draws in again each time its tab opens.
- Pass groupKey to color dots by a field, with one config entry per group value.
- Pass valueFormat, like { style: "currency", currency: "USD" }, to format the tooltip and the axis.
Avoid it for
- Thousands of dots. They shrink to fit and then squeeze together. Use a histogram, or a BarChart of counts.
- A trend over time. Use LineChart or AreaChart.
- Comparing a few totals. Use BarChart.
- More than five groups. The theme has five chart colors.
Props
- data
One object per dot, like { page: "Home", seconds: 1.2 }.
TData[]
- valueKey
The number field that places each dot along the axis.
keyof TData
- config
Without groupKey, one entry keyed by valueKey. With groupKey, one entry per group value. Colors default to --chart-1 through --chart-5.
{ [key: string]: { label: string; color?: string } }
- labelKey
The field that names each dot in the tooltip.
keyof TData
- groupKey
The field that puts each dot in a group. Dots whose group has no config entry are left out.
keyof TData
- title
A visible title. It names the chart. TypeScript requires title, aria-label, or aria-labelledby.
ReactNode
- aria-label
Names the chart when it has no visible title.
string
- size
Height: 5, 7, or 9 times --control-h-md, so density scales it.
"sm" | "md" | "lg" · default "md"
- valueFormat
Formats tooltip values, and axis ticks in short form, like $12.3K.
Intl.NumberFormatOptions
- locale
The locale for numbers. Fixed, so server and browser print the same text.
string · default "en-US"
- legend
Shows the legend. By default it shows when there are two or more groups.
boolean
- loading
Shows a loose swarm of skeleton dots at the same height. When data arrives, they fade out while the dots come in.
boolean · default false
- error
Shows this message in place of the chart. true shows a default message.
ReactNode
- onRetry
Shows a retry button under the error.
() => void
- emptyText
Shown when no dot has a number and a config entry.
ReactNode · default "No data yet"
- className
Extra classes. Later classes win, so className='h-96' replaces the height.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| data | TData[] | - | One object per dot, like { page: "Home", seconds: 1.2 }. |
| valueKey | keyof TData | - | The number field that places each dot along the axis. |
| config | { [key: string]: { label: string; color?: string } } | - | Without groupKey, one entry keyed by valueKey. With groupKey, one entry per group value. Colors default to --chart-1 through --chart-5. |
| labelKey | keyof TData | - | The field that names each dot in the tooltip. |
| groupKey | keyof TData | - | The field that puts each dot in a group. Dots whose group has no config entry are left out. |
| title | ReactNode | - | A visible title. It names the chart. TypeScript requires title, aria-label, or aria-labelledby. |
| aria-label | string | - | Names the chart when it has no visible title. |
| size | "sm" | "md" | "lg" | "md" | Height: 5, 7, or 9 times --control-h-md, so density scales it. |
| valueFormat | Intl.NumberFormatOptions | - | Formats tooltip values, and axis ticks in short form, like $12.3K. |
| locale | string | "en-US" | The locale for numbers. Fixed, so server and browser print the same text. |
| legend | boolean | - | Shows the legend. By default it shows when there are two or more groups. |
| loading | boolean | false | Shows a loose swarm of skeleton dots at the same height. When data arrives, they fade out while the dots come in. |
| error | ReactNode | - | Shows this message in place of the chart. true shows a default message. |
| onRetry | () => void | - | Shows a retry button under the error. |
| emptyText | ReactNode | "No data yet" | Shown when no dot has a number and a config entry. |
| className | string | - | Extra classes. Later classes win, so className='h-96' replaces the height. |
Accessibility
- Renders a <figure> named by its title, or by aria-label. TypeScript requires one.
- Recharts' keyboard layer stays on: Tab focuses the chart, the arrow keys step through every dot from the lowest value to the highest, and the tooltip reads each one out.
- The dot the tooltip describes gets a ring, so the arrow keys show where they are.
- Keeps one height while it loads, draws, fails, or has no data, so the page does not move. When the page prints, it asks the browser to keep a chart that fits on one page whole. In the light theme, it asks the browser to print legend dots and loading shapes even with backgrounds off.
- Loading, the error, and the empty text are read out through a status line that stays on the page.
- Group colors come from --chart-1 to --chart-5, which the theme tests hold to 3:1 against the page. The legend and tooltip name each group in text.
- Dots grow in over three times --motion-duration-base, once the chart is on screen. When the data changes, they move, grow, and shrink over the same time. At 0ms they draw at once, and Recharts skips the animation for reduced motion.
Examples
One group
const pages = [
{ page: "Home", seconds: 1.2 },
{ page: "Pricing", seconds: 1.3 },
{ page: "Docs", seconds: 1.25 },
{ page: "Blog", seconds: 2.4 },
];
<BeeswarmChart
title="Load time by page"
data={pages}
valueKey="seconds"
labelKey="page"
config={{ seconds: { label: "Load time" } }}
valueFormat={{ style: "unit", unit: "second", maximumFractionDigits: 2 }}
/>Colored by group
<BeeswarmChart
title="Load time by device"
data={[
{ page: "Home", seconds: 1.2, device: "mobile" },
{ page: "Home", seconds: 0.7, device: "desktop" },
{ page: "Docs", seconds: 1.6, device: "mobile" },
{ page: "Docs", seconds: 0.9, device: "desktop" },
]}
valueKey="seconds"
labelKey="page"
groupKey="device"
config={{ mobile: { label: "Mobile" }, desktop: { label: "Desktop" } }}
/>Data that follows tabs
const [week, setWeek] = useState("this");
<Tabs value={week} onValueChange={(next) => setWeek(String(next))}>
<TabsList aria-label="Week">
<TabsTab value="this">This week</TabsTab>
<TabsTab value="last">Last week</TabsTab>
</TabsList>
<TabsPanel value={week}>
<BeeswarmChart
title="Load time by page"
data={week === "this" ? thisWeek : lastWeek}
valueKey="seconds"
labelKey="page"
config={{ seconds: { label: "Load time" } }}
/>
</TabsPanel>
</Tabs>While it loads, and when it fails
<BeeswarmChart
aria-label="Load time by page"
data={data ?? []}
valueKey="seconds"
config={{ seconds: { label: "Load time" } }}
loading={isLoading}
error={error ? "Load times could not load." : undefined}
onRetry={() => refetch()}
/>