Hex Chart
A whole broken into parts, counted out in hexagonal cells.
'use client';
import { useState } from 'react';
import { HexChart } from '@/components/panelui/hex-chart';
import { Frame } from '@/components/panelui/frame';
const ATTRIBUTION = [
{ label: 'Organic', value: 2_880 },
{ label: 'Paid search', value: 1_740 },
{ label: 'Referral', value: 1_120 },
{ label: 'Email', value: 640 },
{ label: 'Social', value: 370 },
];
const dollars = (value: number) => `$${value.toLocaleString('en-US')}`;
const total = ATTRIBUTION.reduce((sum, { value }) => sum + value, 0);
export default function HexChartOverview() {
const [active, setActive] = useState(-1);
const series = ATTRIBUTION[active];
return (
<Frame className="w-full max-w-lg" variant="band">
<Frame.Header>
<Frame.Title>Attribution</Frame.Title>
<Frame.Action>This quarter</Frame.Action>
</Frame.Header>
<Frame.Panel className="p-5">
<HexChart data={ATTRIBUTION} label="Revenue by channel" onActiveIndexChange={setActive}>
<HexChart.Header
title={series ? series.label : 'Attributed revenue'}
value={dollars(series ? series.value : total)}
caption={series ? `${Math.round((series.value / total) * 100)}% of the total` : 'Across five channels'}
/>
<HexChart.Cells />
<HexChart.Tooltip formatValue={dollars} />
<HexChart.Legend />
</HexChart>
</Frame.Panel>
</Frame>
);
}Use it to show how one total divides, in a form a reader can count: revenue by channel, seats by team. Each item in data has a label and a value, and takes a share of the cells in proportion.
Every cell is a whole unit, so a series worth less than a cell can disappear. It suits shares of a few percent and up. For a long tail, use a Bar Chart.
Installation
npx shadcn@latest add https://panelui.dev/r/web/hex-chart.jsonpnpm dlx shadcn@latest add https://panelui.dev/r/web/hex-chart.jsonyarn dlx shadcn@latest add https://panelui.dev/r/web/hex-chart.jsonbunx shadcn@latest add https://panelui.dev/r/web/hex-chart.jsonThe source is copied into components/panelui/, where you can edit it.
Usage
import { HexChart } from "@/components/panelui/hex-chart"<HexChart data={attribution}>
<HexChart.Cells />
<HexChart.Tooltip />
<HexChart.Legend />
</HexChart>Composition
<HexChart data={…}>
<HexChart.Header />
<HexChart.Cells />
<HexChart.Skeleton />
<HexChart.Tooltip />
<HexChart.Legend />
</HexChart>HexChart.Header— The strip above the field:title,valueandcaption, with a key or your own control on the trailing side.HexChart.Cells— The honeycomb: the unfilled field and the series over it. Without this part nothing is drawn.HexChart.Skeleton— The field with nothing divided up, tinted, whilestatusisloading.HexChart.Tooltip— Makes the field readable by pointer and keyboard, and hangs a label above the series being read.HexChart.Legend— A swatch, a name and a share per series, under the field. Each is a button that selects its series.
Examples
Counting off a grid
shape="grid" fills the whole field in reading order and in data order, so a reader can count cells along a row. showCells on the tooltip adds the count to the label.
'use client';
import { HexChart } from '@/components/panelui/hex-chart';
const SEATS = [
{ label: 'Engineering', value: 48 },
{ label: 'Sales', value: 26 },
{ label: 'Support', value: 14 },
{ label: 'Design', value: 12 },
];
export default function HexChartGrid() {
return (
<HexChart data={SEATS} shape="grid" columns={14} aspectRatio={2} className="w-full max-w-lg">
<HexChart.Cells />
<HexChart.Tooltip showCells formatValue={(value) => `${value} seats`} />
<HexChart.Legend />
</HexChart>
);
}Field size and density
columns sets how many cells run across, which also sets the size of one. density is how much of the field a blob fills. Fewer, larger cells suit a small split. More cells show small shares.
'use client';
import { HexChart } from '@/components/panelui/hex-chart';
const ATTRIBUTION = [
{ label: 'Organic', value: 2_880 },
{ label: 'Paid search', value: 1_740 },
{ label: 'Referral', value: 1_120 },
{ label: 'Email', value: 640 },
{ label: 'Social', value: 370 },
];
export default function HexChartDensity() {
return (
<div className="grid w-full max-w-2xl gap-6 sm:grid-cols-2">
<HexChart data={ATTRIBUTION} columns={12} density={0.7} aspectRatio={1.3}>
<HexChart.Header title="12 columns, 70% full" />
<HexChart.Cells />
<HexChart.Tooltip />
</HexChart>
<HexChart data={ATTRIBUTION} columns={30} density={0.4} aspectRatio={1.3}>
<HexChart.Header title="30 columns, 40% full" />
<HexChart.Cells />
<HexChart.Tooltip />
</HexChart>
</div>
);
}Selecting from outside
Pass activeIndex and onActiveIndexChange to keep the selection yourself, for a control elsewhere on the page. The value is -1 when nothing is selected.
'use client';
import { useState } from 'react';
import { Button } from '@/components/panelui/button';
import { HexChart } from '@/components/panelui/hex-chart';
const ATTRIBUTION = [
{ label: 'Organic', value: 2_880 },
{ label: 'Paid search', value: 1_740 },
{ label: 'Referral', value: 1_120 },
{ label: 'Email', value: 640 },
{ label: 'Social', value: 370 },
];
export default function HexChartControlled() {
const [active, setActive] = useState(-1);
return (
<div className="flex w-full max-w-lg flex-col gap-4">
<div className="flex flex-wrap gap-2">
{ATTRIBUTION.map(({ label }, index) => (
<Button key={label} size="sm" variant={active === index ? 'default' : 'outline'} onClick={() => setActive(active === index ? -1 : index)}>
{label}
</Button>
))}
</div>
<HexChart data={ATTRIBUTION} activeIndex={active} onActiveIndexChange={setActive}>
<HexChart.Cells />
<HexChart.Tooltip />
</HexChart>
</div>
);
}Loading, then data
Add HexChart.Skeleton and drive status. The field shows undivided, because placeholder shares would be a made-up split. The series fill in from the middle when it turns ready.
'use client';
import { useState } from 'react';
import { Button } from '@/components/panelui/button';
import { HexChart } from '@/components/panelui/hex-chart';
const ATTRIBUTION = [
{ label: 'Organic', value: 2_880 },
{ label: 'Paid search', value: 1_740 },
{ label: 'Referral', value: 1_120 },
{ label: 'Email', value: 640 },
{ label: 'Social', value: 370 },
];
export default function HexChartLoading() {
const [ready, setReady] = useState(false);
return (
<div className="flex w-full max-w-lg flex-col items-start gap-4">
<HexChart data={ready ? ATTRIBUTION : []} status={ready ? 'ready' : 'loading'} className="w-full">
<HexChart.Skeleton />
<HexChart.Cells />
<HexChart.Tooltip />
</HexChart>
<Button variant="outline" onClick={() => setReady((current) => !current)}>
{ready ? 'Load again' : 'Finish loading'}
</Button>
</div>
);
}Changing data
When data changes, each cell takes its new series. A ref on the chart has replay(), which fills the series in again.
'use client';
import { useRef, useState } from 'react';
import { Button } from '@/components/panelui/button';
import { HexChart, type HexChartHandle } from '@/components/panelui/hex-chart';
const ATTRIBUTION = [
{ label: 'Organic', value: 2_880 },
{ label: 'Paid search', value: 1_740 },
{ label: 'Referral', value: 1_120 },
{ label: 'Email', value: 640 },
{ label: 'Social', value: 370 },
];
const NEXT = [
{ label: 'Organic', value: 1_900 },
{ label: 'Paid search', value: 2_600 },
{ label: 'Referral', value: 700 },
{ label: 'Email', value: 1_300 },
{ label: 'Social', value: 620 },
];
export default function HexChartChangingData() {
const chart = useRef<HexChartHandle>(null);
const [later, setLater] = useState(false);
return (
<div className="flex w-full max-w-lg flex-col items-start gap-4">
<HexChart ref={chart} data={later ? NEXT : ATTRIBUTION} className="w-full">
<HexChart.Cells />
<HexChart.Tooltip />
<HexChart.Legend />
</HexChart>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setLater((current) => !current)}>
{later ? 'Back to this quarter' : 'Next quarter'}
</Button>
<Button variant="ghost" onClick={() => chart.current?.replay()}>
Replay
</Button>
</div>
</div>
);
}API Reference
HexChart
| Prop | Type | Default | Description |
|---|---|---|---|
data | readonly HexDatum[] | — | The slices of the whole. |
columns | number | 21 | Cells across the field. Rows follow from the width, the height and this, and the size of one cell follows from all three. |
aspectRatio | number | 1.6 | The field's width ÷ height. |
density | number | 0.55 | How much of the field a blob fills, from 0 to 1. A grid always fills all of it. The rest is left as the unfilled cells around the shape. |
shape | HexShape | 'blob' | blob grows from the middle and shows the split at a glance. grid is reading order, for counting. |
cellGap | number | 0.14 | Space between cells, as a share of a cell's radius. |
animationDuration | number | 620 | Milliseconds for the series to fill in. 0 draws them at once. |
status | HexChartStatus | 'ready' | loading shows the field with nothing divided up yet. Add a HexChart.Skeleton to tint it. |
activeIndex | number | — | The series being read, by index, or -1 for none. Leave unset to let the chart keep it. |
onActiveIndexChange | (index: number) => void | — | The series being read, by index, and -1 when there is none. Reading needs HexChart.Tooltip. |
label | string | — | What a screen reader calls the chart. |
labelForDatum | (series: HexDatum, index: number) => string | — | What a screen reader says for one series. Defaults to its name and share. |
HexChart.Cells
| Prop | Type | Default | Description |
|---|---|---|---|
emptyColor | string | — | Colour of the cells no series took, a theme role or any CSS colour. Defaults to the muted surface. |
dimOpacity | number | 0.25 | Opacity of the series that are not being read, once one is. |
HexChart.Skeleton
| Prop | Type | Default | Description |
|---|---|---|---|
color | string | — | A theme role or any CSS colour. Defaults to the muted surface. |
HexChart.Tooltip
| Prop | Type | Default | Description |
|---|---|---|---|
formatValue | (value: number, series: HexDatum) => string | — | Format the series' value. Defaults to a compact number. |
showCells | boolean | false | Show the count of cells beside the share. |
className | string | — | Added to the readout's surface. |
HexChart.Legend
| Prop | Type | Default | Description |
|---|---|---|---|
showValue | boolean | true | Show each series' share of the whole beside its name. |
HexChart.Header
| Prop | Type | Default | Description |
|---|---|---|---|
title | ReactNode | — | Small line above the value: what the chart is of. |
value | ReactNode | — | The readout. The largest thing on the card, and the first thing read. |
caption | ReactNode | — | One muted line under the value: a period, a comparison, a caveat. |
labels | Record<string, string> | — | Prettier names for the series, keyed by their label. |
legend | boolean | false | Draw a swatch and a name per series along the trailing edge. For two or three short names. Past that use HexChart.Legend, which runs across the full width. |
HexChart, HexChart.Header and HexChart.Legend also take the props of the element they render, and a className. HexChart.Tooltip puts its className on the label. The other parts draw nothing themselves and take only the props listed.
Notes
Sizing
The chart fills its container's width and takes its height from aspectRatio, which applies to the field alone, so a header and a legend do not shrink it. Rows follow from the width, the height and columns.
Reading
A mouse reads the series under it. A tap or a click pins the reading, and pressing the same series again or an unfilled cell clears it. Once the field has focus, the arrow keys walk the series and Escape clears the reading. All of this comes from HexChart.Tooltip. Without it, only the legend selects.
Accessibility
The chart renders a list of its series for screen readers, and announces the series being read. label and labelForDatum set what it says. With reduced motion set, the series appear at once without the fill. animationDuration={0} draws them at once for everybody.
Server Components
HexChart is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read HexChart.Header off the import, so each part is also exported by name: HexChartHeader is HexChart.Header, and so on for the others.
import { HexChart, HexChartHeader, HexChartCells, HexChartTooltip, HexChartLegend, HexChartSkeleton } from "@/components/panelui/hex-chart"