Skip to main content

UnitChart

Part to Wholechart

One mark = one honest unit — waffle grid, phyllotaxis field or tick rows, so a share is COUNTED rather than judged by angle or area; lieflat's default replacement for a pie.

38 + 31 + 17 + 14 = 100

Ledger38
Billing31
Usage Metering17
Studio14
import { UnitChart } from "@elabs-ai/components-charts";
Install
npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-charts

Skipping the @source line is the most common first failure: Tailwind does not scan node_modules, so every component renders unstyled. The one-time wiring

Prompt for your coding agent

Paste it into Claude Code, Cursor, Codex or any other coding agent. Text in [brackets] is yours to fill in.

Add the brand-ui UnitChart chart (from @elabs-ai/components-charts) to this project.

1. Read https://elabs-ai.com/llms.txt first: it maps every package and the rules. If you can use MCP servers, connect https://elabs-ai.com/mcp and use its search, docs and tokens tools. If not, run the same lookups in the terminal: `npx -y @elabs-ai/components-cli search "<what you need>"` and `npx -y @elabs-ai/components-cli docs <Component>`.
2. Look UnitChart up before writing code: `npx -y @elabs-ai/components-cli docs UnitChart` prints its real props, variants, what it pairs with and what to avoid.
3. If @elabs-ai/components-charts is not installed yet: `pnpm add @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-charts` (or `npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-charts`). If this is the first brand-ui package in the project, wire it once: import `@elabs-ai/components-tokens/styles.css` plus the `themes/light.css` and `themes/dark.css` stylesheets in the CSS entry, add one Tailwind `@source` line per installed @elabs-ai package (Tailwind does not scan node_modules, so without it everything renders unstyled), and wrap the app root in `ThemeProvider` from `@elabs-ai/components-tokens`.
4. Use it here: [the screen or file, and what it should show]
5. Rules: use only @elabs-ai/components-* components and the semantic tokens — no raw colours, no raw font sizes, no arbitrary Tailwind values. Look up each component's real props with the docs tool before you use it; never guess a prop.
6. Done means: the typecheck passes, `npx -y @elabs-ai/components-cli audit src --strict` reports nothing, and you have opened the app and looked at every screen you touched in the light and the dark theme.

Use it for

  • What is it made of? A whole, counted.
  • parts of a whole as discrete unit counts rather than a percentage, as layout="waffle"
  • one tally row per category, ticks summing to a total — ticket volume by weekday, for example, as layout="rows"

Avoid

  • exact per-unit counts do not matter — a pie or bar chart reads faster
  • Sizing a waffle or field past a few hundred marks in a small frame — one stack stops being countable past ~60 units and here that failure spans the WHOLE canvas, so budget total, columns and frame size together.
  • Turning showArithmetic off (or padding a count) because a rounded-away caption looks untidy — the caption exists BECAUSE the chart never invents a mark to hit total; hiding it trades the one honest-count promise this chart makes for a tidier footer.
  • Reaching for layout="field" when the reader needs to rank or compare series precisely — a phyllotaxis cluster reads as an area impression, not a countable structure; a real comparison wants rows or waffle.
  • Falling back to a pie chart because comparing shares 'still feels easier' — a slice angle cannot be counted, a mark can. If the categories don't sum to a meaningful whole, use the rows layout, not a pie.

Works with

Contains

Pairs with

Examples

15 live examples

Default

Waffle — column-major grid, one dot per unit (lieflat's "G4 Dot Waffle").

Loading

Loading skeleton (RM-183) — shown while `status="loading"`, sized like the real chart.

draws once loading finishes

Regression lock (RM-183 review) — the ResizeObserver that measures the plot never attached while `status="loading"` hid the real chart node, so flipping to "ready" left the waffle blank until an unrelated resize fired. This exercises that exact transition end to end in a real browser.

Field

Field — golden-angle phyllotaxis cluster per series (lieflat's "L14 Hundred Field").

Rows

Rows — one tick row per series, ticked every 10 (lieflat's "L15 Ballot Tally"). Rows may sum past `total`.

In a short ChartFrame (plotHeight=160)

A short `ChartFrame plotHeight` (RM-183 review, F12 follow-up): once the legend takes its share of a 160px box, the waffle/field plot must still keep a real minimum height instead of being squeezed to a sliver — `plotMinHeight` in `unit-chart.tsx` reserves rows × a minimum mark row height (waffle) or a flat floor (field) before the legend gets anything. Widened to 600px (RM-183 review round 2, F1 regression): `plotHeight` sizes the PLOT only — the root stays auto height — so the legend must always end AT OR ABOVE the root's own bottom edge, never past it. A 420px-wide box hid the legend at this row count entirely, which is why the original story's assertion never caught the regression.

Own plotHeight prop at 400 / 160 / 60, no ChartFrame

`UnitChart`'s own `plotHeight` prop (no `ChartFrame` involved) at three heights — a regression lock for G1 (RM-183 review round 2): `ChartPlotBox` merged a caller's own inline `style` onto its already-resolved box style with a plain `{ ...boxStyle, ...style }` spread, and `unit-chart.tsx` built that `style` as `{ height: layout === "rows" ? rowsHeight : undefined, ... }` — an explicitly-`undefined`-valued `height` key still shadows `boxStyle`'s own resolved `height` in a spread (present-but-empty is not absent), so EVERY waffle/field plot rendered at the bare 100px content floor no matter what `plotHeight` asked for. Fixed at both ends: `unit-chart.tsx` no longer builds that key, and `ChartPlotBox`/`ChartPlotRoot` now drop an undefined-valued key from a caller's `style` before merging.

In a 300px ChartFrame at two widths (M3 check)

M3 (RM-183 review round 2, contained/optional): a 300px `ChartFrame` at two widths. Before G1's fix, the plot's requested height was silently erased and it grew to its own aspect ratio instead, pushing the legend past the frame's bottom edge; this re-checks that G1's fix (rather than a separate one) already closes it.

All Layouts

Waffle / Field / Rows side by side — the acceptance's cross-layout comparison.

Rounding Remainder

The rounding rule: a series that does not divide evenly into `unit` never invents a mark — the footer calls out the shortfall instead ("98 · 2 rounded away").

Mark Shapes

`mark="square"` and `mark="tick"` — the two alternates to the default dot.

Outline Variant

A per-datum `variant: "outline"` draws that series hollow (stroke only, no fill) — a second channel beside colour for "this is the one series that matters" (WCAG 1.4.1), and it keeps a quiet majority from visually dominating a highlighted minority the way a solid dark fill would. Default (`variant` unset) stays a solid fill, unchanged.

Performance Stress

1,000 marks across 5 series. The per-mark reveal is a plain CSS `transition-delay` computed once in JS (`unitMarkDelayMs`) — never a per-mark `motion`/framer-motion component — so this stays a single paint regardless of mark count. See `unit-layouts.ts`'s stagger constants.

Drilldown

Click a series — or Tab in and press Enter — to drill into it. One keyboard target per series, never per mark.

Selection states

`selectionStates` paints the host’s tri-state on each category’s marks: selected marks carry a compound foreground/background outline, excluded marks dim AND carry a full-opacity dashed frame, so the three states stay distinguishable in greyscale.

API

UnitChart

PropTypeDescription
datarequiredUnitChartDatum[]The series — one labeled quantity per row.
layoutrequiredUnitChartLayoutWhich lieflat layout to draw.
totalnumberWhat 100% of the marks represents. Default 100 (percent). Ignored by `rows`.
unitnumberValue per mark. Default 1 — a `unit={2}` chart draws one mark per two incidents.
unitLabelstring"one dot = one person in a hundred" — rendered as a caption and folded into the a11y summary.
columnsnumberGrid columns for `waffle`. Default 10.
markUnitChartMarkMark shape for `waffle`/`field`. Default `"dot"`.
paletteChartPaletteColor family. Default categorical; left unset, past 6 series it degrades to the neutral ladder and warns (RM-018). Passing it — even `"categorical"` — is an explicit choice and is honoured at any series count.
showArithmeticbooleanShow the footer arithmetic ("41 + 35 + 12 + 12 = 100 · 2 rounded away"). Default `true`. Ignored by `rows`.
sort"desc" | "none"Sort series by value, descending, before laying out. Default `"none"` (caller order).
accessibleLabelChartA11yProps["accessibleLabel"]Accessible name for the chart region.
accessibleDescriptionChartA11yProps["accessibleDescription"]Supplemental description read by AT.
selectionStatesChartSelectionStatesResolver<Record<string, unknown>>Maps a mark (category, optional series key, optional raw row) to its selection state. Unset → the chart renders exactly as before.
dimExcludedbooleanDim `excluded` marks and add their non-hue channel (the dashed frame). Default `true`; `false` keeps the `data-selection` attribute (so a host can style it) but paints nothing.
onDatapointClickChartDatapointClickHandler<Record<string, unknown>>Fires when a datapoint is activated by pointer OR keyboard. Setting it is what enables the interaction layer — with it unset the chart renders exactly as before, with no extra DOM and no new focusables.
copyValueOnActivatebooleanPut the datapoint's EXACT value on the clipboard when it is activated — the recovery path for compact axis labels and tooltips, reachable by mouse AND keyboard because activation rides `ChartDatapointLayer`'s real buttons. Default `false` on a raw chart container: turning it on mounts the interaction layer, and the documented guarantee is that a chart with no interaction props renders byte-identical DOM. `AutoChart` — the spec-driven surface, where the caller never sees the axis config — turns it on by default. A consumer-supplied `onDatapointClick` always wins; the two never both run.
datapointLabelChartDatapointLabel<Record<string, unknown>>Override the accessible name of each keyboard target.
maxInteractiveDatapointsnumberDev-warning threshold on the number of keyboard targets. Default 500. NOT a functional cap — every plotted point stays reachable, so a keyboard user can reach exactly what a mouse user can click.
plotHeightResponsive<ChartPlotHeight>Height of the plot: pixels, or `{ aspect }` as width divided by height. Set `{ base, medium, narrow }` to vary it by breakpoint.
marginnumber | Partial<Margin>Space around the plot, in pixels: one number for every side, or per side.
statusChartStatusShow the loading skeleton until the data is ready.
emptyChartEmptyStateTitle and message shown when there is nothing to plot.
tooltipboolean

Also accepts: ChartSelectionProps, ChartInteractionProps, Pick<FrameSizeGroupProps, "margin" | "plotHeight">, Pick<ChartStateGroupProps, "status" | "empty">, Pick<TooltipGroupProps, "tooltip">, Omit<HTMLAttributes<HTMLDivElement>, "color">

UnitStack

PropTypeDescription
nrequirednumberHow many units to draw. Negative and fractional values are floored to a count.
kindrequiredUnitStackKind`rung` (crossbar), `tick` (half-crossbar), or `dot`.
directionrequiredUnitStackDirectionWhich way the stack grows from `(x, y)`.
lengthrequirednumberCross-axis extent of one ORDINARY unit in px — the rung's width, or the dot's diameter. An every-`markEvery`-th mark renders `length * UNIT_STACK_EMPHASIS` (centred for `"rung"`), so a caller with a hard cross-axis bound (e.g. a bar's own width) must pass `bound / UNIT_STACK_EMPHASIS`, not `bound` — otherwise the emphatic mark overruns the bound on both sides (#241).
stepnumberDistance between consecutive unit centres in px (default 3).
markEverynumberEvery n-th unit is drawn longer and heavier so the stack stays countable (default 5).
originOffsetnumberShifts every mark's growth-axis position by `originOffset * step` from `(x, y)` — e.g. `originOffset={1}` places mark 0 one full `step` from the origin instead of AT it, so a caller can terminate the ladder flush with a value's own end rather than one unit short of it (default 0, #241).
jitterbooleanVary each unit's stroke width and opacity through {@link seededRnd} (default false).
seedrequirednumberThe stack's seed — the `k` of {@link seededRnd}. Required whenever `jitter` is on.
xnumberOrigin x (default 0).
ynumberOrigin y (default 0).

Also accepts: Omit< SVGProps<SVGGElement>, "direction" | "seed" | "kind" | "length" >

Leader

PropTypeDescription
fromrequiredLeaderPointWhere the leader starts — normally the mark it points at.
torequiredLeaderPointWhere the leader ends — normally the edge of the label.
kindLeaderKindPath shape (default `elbow`).
dashLeaderDashDash rhythm (default `1 3`).
arrowbooleanDraw a solid arrow head at `from`, pointing at the mark (RM-111). Default `false` — a leader is a quiet trail unless the callout must be followed.

Also accepts: Omit<SVGProps<SVGPathElement>, "from" | "to" | "d">

ChartLegend

PropTypeDescription
itemsrequiredLegendItem[]Legend items to display
hoveredIndexnumber | nullCurrently hovered index (for highlight effect)
onHover(index: number | null) => voidCallback when an item is hovered
onItemClick( item: LegendItem, index: number, event: React.MouseEvent | React.KeyboardEvent, ) => voidDrill-down on a legend entry (#349). When set, each item renders as a real `<button>` — keyboard operable and named by the item's label — instead of a `<div>`. Unset, the legend is unchanged (a non-interactive `<div>` list). NOTE this lives on `ChartLegend`, not on the chart containers: the legend is a separately-placed composition primitive, so a callback on the container would be a prop the container cannot honour.
hiddenKeysReadonlySet<string>Series toggle (RM-118, `legend={{ interactive: "toggle" }}`). Set together with `onToggleKey`: every item renders as a real `<button aria-pressed>` — native keyboard support, no extra wiring — and `hiddenKeys` marks which ones read `aria-pressed="false"` and paint dimmed with a struck-through label (WCAG 1.4.1: hidden is never colour-only). Independent of `onItemClick` (drill-down); a container sets one or the other.
onToggleKey(key: string, item: LegendItem, index: number) => voidToggles one item's hidden state. Keyed by `item.key ?? item.label`.
toggleControl"item" | "checkbox"What hides an item when `onToggleKey` is set. `"item"` (default): the whole entry is the `aria-pressed` toggle. `"checkbox"`: the entry stays free for `onItemClick` (a host's selection, say) and a small checkbox after it toggles visibility. The checkbox shows while the row is hovered or holds focus, and always on touch screens, which cannot hover. A hidden entry stays in the legend, muted and struck through, in both modes.
hideAtDensityreadonly ChartDensity[]Density tiers that hide the whole legend (RM-072 default: `xs`/`sm`). The container legend engine (`useContainerLegend`, RM-118) passes `["xs"]` — `sm` renders `stack`-laid-out instead of hiding — so this stays `["xs", "sm"]` for every OTHER (direct) caller, unchanged.
showProgressbooleanShow progress bars. Default: false
showMarkerbooleanShow color marker dot. Default: true
showValuebooleanShow numeric value column. Default: true
showPercentagebooleanShow percentage value. Default: true when showProgress is true
formatValue(value: number) => stringFormat function for displaying values. Overrides `valueFormat` entirely when set.
valueFormatChartValueFormatHow to format `value`/`maxValue` (RM-109) — a preset or the object form (`{ decimals, abbreviate, sign, prefix, suffix, … }`, `value-format.ts`). Resolved as a SET across every item (#250, `useChartValueSetFormatter`) so the legend never mixes "1K" beside "400". Default: locale-aware plain grouping (`intFmt`), unchanged from before this prop existed.
currencystringISO 4217 code for `valueFormat: "currency"`. Falls back to `ChartConfigProvider`, then `"USD"`.
maxFractionDigitsnumberMost digits after the decimal point in the value column (RM-187).
localestringLocale for the value and percent columns (RM-187); unset, the `LocaleProvider`'s.
titlestringTitle shown above the legend
layout"row" | "stack" | "split"Item flow direction (RM-118, sitting 3). `"stack"` (default — every caller before this prop existed) is byte-identical to before: one item per line. `"row"` wraps items left-to-right (`flex-wrap`), matching the layout `AutoChart`'s old `AutoLegend` used, AND drops the `w-full` an interactive (`onItemClick`/toggle) item otherwise gets — `w-full` inside a row would force every item onto its own line regardless of this prop. `useContainerLegend` (the container legend engine) passes its own resolved `layout` ("row" at `base`/`medium`/`wide`, "stack" at `narrow` or density `sm`) through; a direct caller may set its own or leave it unset for the original stacked behaviour.
classNamestringAdditional class name for the container
titleClassNamestringClass name for the title
itemClassNamestringClass name for each legend item
labelClassNamestringClass name for the label
valueClassNamestringClass name for the value
renderItem(props: { item: LegendItem; index: number; isHovered: boolean; isFaded: boolean; percentage: number; }) => ReactNodeCustom render function for legend items. `item.value` may be `NaN`, meaning the item has no value (a container legend entry with no number of its own): check `Number.isFinite(item.value)` before printing it. `percentage` is 0 for such an item.
splitGroupsreadonly ChartLegendSplitGroup[]`layout="split"` (RM-121): one row per value axis, each led by its side label. Items not named by any group are omitted. Unset, `"split"` renders as `"row"`.
splitStackedbooleanStack the split rows vertically (the narrow tier). Default `false`: side by side, wrapping.

Theming

The tokens each state resolves to. Change the token and every theme follows.

StateToken
marksfill/stroke resolved per series from the active ChartPalette (--chart-1.. or the mono ladder past 6 series) — never a raw hex
leaderfield's cluster-to-label callout is a 0.6px var(--chart-foreground-muted) dashed hairline — furniture, never a second data channel
captiontext-caption in var(--chart-label) for unitLabel, var(--chart-foreground-muted) tabular-nums for the arithmetic remainder
rowValuerows' value at the inked edge is var(--chart-foreground) at font-weight 800 — the one heavy mark in the row