38 + 31 + 17 + 14 = 100
import { UnitChart } from "@elabs-ai/components-charts";npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-chartsPaste 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
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
| Prop | Type | Description |
|---|---|---|
datarequired | UnitChartDatum[] | The series — one labeled quantity per row. |
layoutrequired | UnitChartLayout | Which lieflat layout to draw. |
total | number | What 100% of the marks represents. Default 100 (percent). Ignored by `rows`. |
unit | number | Value per mark. Default 1 — a `unit={2}` chart draws one mark per two incidents. |
unitLabel | string | "one dot = one person in a hundred" — rendered as a caption and folded into the a11y summary. |
columns | number | Grid columns for `waffle`. Default 10. |
mark | UnitChartMark | Mark shape for `waffle`/`field`. Default `"dot"`. |
palette | ChartPalette | Color 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. |
showArithmetic | boolean | Show 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). |
accessibleLabel | ChartA11yProps["accessibleLabel"] | Accessible name for the chart region. |
accessibleDescription | ChartA11yProps["accessibleDescription"] | Supplemental description read by AT. |
selectionStates | ChartSelectionStatesResolver<Record<string, unknown>> | Maps a mark (category, optional series key, optional raw row) to its selection state. Unset → the chart renders exactly as before. |
dimExcluded | boolean | Dim `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. |
onDatapointClick | ChartDatapointClickHandler<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. |
copyValueOnActivate | boolean | Put 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. |
datapointLabel | ChartDatapointLabel<Record<string, unknown>> | Override the accessible name of each keyboard target. |
maxInteractiveDatapoints | number | Dev-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. |
plotHeight | Responsive<ChartPlotHeight> | Height of the plot: pixels, or `{ aspect }` as width divided by height. Set `{ base, medium, narrow }` to vary it by breakpoint. |
margin | number | Partial<Margin> | Space around the plot, in pixels: one number for every side, or per side. |
status | ChartStatus | Show the loading skeleton until the data is ready. |
empty | ChartEmptyState | Title and message shown when there is nothing to plot. |
tooltip | boolean |
UnitStack
| Prop | Type | Description |
|---|---|---|
nrequired | number | How many units to draw. Negative and fractional values are floored to a count. |
kindrequired | UnitStackKind | `rung` (crossbar), `tick` (half-crossbar), or `dot`. |
directionrequired | UnitStackDirection | Which way the stack grows from `(x, y)`. |
lengthrequired | number | Cross-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). |
step | number | Distance between consecutive unit centres in px (default 3). |
markEvery | number | Every n-th unit is drawn longer and heavier so the stack stays countable (default 5). |
originOffset | number | Shifts 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). |
jitter | boolean | Vary each unit's stroke width and opacity through {@link seededRnd} (default false). |
seedrequired | number | The stack's seed — the `k` of {@link seededRnd}. Required whenever `jitter` is on. |
x | number | Origin x (default 0). |
y | number | Origin y (default 0). |
Leader
| Prop | Type | Description |
|---|---|---|
fromrequired | LeaderPoint | Where the leader starts — normally the mark it points at. |
torequired | LeaderPoint | Where the leader ends — normally the edge of the label. |
kind | LeaderKind | Path shape (default `elbow`). |
dash | LeaderDash | Dash rhythm (default `1 3`). |
arrow | boolean | Draw 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. |
ChartLegend
| Prop | Type | Description |
|---|---|---|
itemsrequired | LegendItem[] | Legend items to display |
hoveredIndex | number | null | Currently hovered index (for highlight effect) |
onHover | (index: number | null) => void | Callback when an item is hovered |
onItemClick | ( item: LegendItem, index: number, event: React.MouseEvent | React.KeyboardEvent, ) => void | Drill-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. |
hiddenKeys | ReadonlySet<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) => void | Toggles 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. |
hideAtDensity | readonly 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. |
showProgress | boolean | Show progress bars. Default: false |
showMarker | boolean | Show color marker dot. Default: true |
showValue | boolean | Show numeric value column. Default: true |
showPercentage | boolean | Show percentage value. Default: true when showProgress is true |
formatValue | (value: number) => string | Format function for displaying values. Overrides `valueFormat` entirely when set. |
valueFormat | ChartValueFormat | How 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. |
currency | string | ISO 4217 code for `valueFormat: "currency"`. Falls back to `ChartConfigProvider`, then `"USD"`. |
maxFractionDigits | number | Most digits after the decimal point in the value column (RM-187). |
locale | string | Locale for the value and percent columns (RM-187); unset, the `LocaleProvider`'s. |
title | string | Title 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. |
className | string | Additional class name for the container |
titleClassName | string | Class name for the title |
itemClassName | string | Class name for each legend item |
labelClassName | string | Class name for the label |
valueClassName | string | Class name for the value |
renderItem | (props: { item: LegendItem; index: number; isHovered: boolean; isFaded: boolean; percentage: number; }) => ReactNode | Custom 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. |
splitGroups | readonly 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"`. |
splitStacked | boolean | Stack 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.
| State | Token |
|---|---|
| marks | fill/stroke resolved per series from the active ChartPalette (--chart-1.. or the mono ladder past 6 series) — never a raw hex |
| leader | field's cluster-to-label callout is a 0.6px var(--chart-foreground-muted) dashed hairline — furniture, never a second data channel |
| caption | text-caption in var(--chart-label) for unitLabel, var(--chart-foreground-muted) tabular-nums for the arithmetic remainder |
| rowValue | rows' value at the inked edge is var(--chart-foreground) at font-weight 800 — the one heavy mark in the row |