import { DumbbellChart } 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 DumbbellChart 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 DumbbellChart up before writing code: `npx -y @elabs-ai/components-cli docs DumbbellChart` 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
- How do these compare? Two points per category.
- two time points per category — a before and after, or a range with two ends
- two measures per category with a direction — the move from one to the other is the fact, drawn as an arrow (variant="arrow")
Avoid
- more than 2 points per category — use small-multiple lines
- Using variant="slope" past the 8-row soft cap — the crossing lines stop being decodable at that density (it dev-warns once); switch to the default variant="dumbbell" (rows) instead of reading the warning as noise.
- Picking a beads.unit so small that round(delta / unit) draws dozens of dots — past a few tens of units a UnitStack reads as a texture smear, not a count; choose a coarser unit and say so in the caption.
- Passing more than 2-3 extraKeys — every extra competitor dot draws on the SAME track as the primary pair, and past a couple they overlap the very marker they were meant to contextualize.
- Turning on showDelta with many closely-spaced rows — the delta label sits fixed beside the end marker with no collision avoidance of its own (unlike the slope columns' spaceSlopeLabels pass), so a small aspectRatio with many rows will overlap labels.
Works with
Contains
Used inside
Examples
Default
F12 Dumbbell Queue — hollow dot = before, ink dot = after, beads between count the units gained.
Loading
`status="loading"` (RM-185): a skeleton fills the same plot box the ready chart would use, at every width, so nothing moves once the data lands.
This Year Vs Last
F6 Paired Rungs — two value columns (last year / this year), one line per category.
Vertical Orientation
Vertical orientation — tracks run as columns instead of rows.
Vertical Orientation Spacious
Same fixture at `data-density="spacious"` (#340: `text-meta` scales +6.25%) — the density knob is what makes a pixel-constant label budget unsafe, so the no-overlap guarantee must hold here too, not just at `comfortable`.
Competitor Dots
L7 Brand Spectrum — extraKeys draws small competitor dots on the shared track.
Slope Overflow Warning
Slope variant past the 8-row soft cap — dev-only console warning, still renders every row with collision-spaced labels (the "legible fallback").
Sorted By Delta
Sorted by delta — the biggest movers surface first, by |delta| (a big decrease outranks a small increase).
With Accessible Label
Accessible variant — announces label + description to screen readers on focus.
Row Color Override
`rowColor` — an "argument, not a chart" infographic emphasises the one or two rows that are the story (a status tone) while the rest stay on the shared palette. Returning `undefined` for a row keeps it on the resolved `palette`, so this is additive: unset, the chart is byte-identical to `Default`.
Slope Both Ends Labeled
`bothEndsLabeled` — the end of each slope line also carries its category name, not just the bare value, so a reader can tell which line is which without tracing it back to the start label. Default (unset) keeps the end label value-only.
Benchmark Reference Line
`referenceLine` + `showValueAxis` — one labelled vertical benchmark line plus light value ticks along the shared scale, in place of repeating the benchmark as a marker on every row. `deltaLabelFormat` shows the gap with a true minus sign and a unit the default `formatValue` sign convention can't.
Selection states
`selectionStates` paints the host’s tri-state on rows: 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.
Arrow plot
`variant="arrow"` — an arrow head at `endKey`, coloured by sign (the diverging positive/negative pair), grouped by channel with a header + separator per group, sorted by `%` change. Head direction is the second (non-hue) channel a signed reading needs (conventions.md, WCAG 1.4.1) — a decrease still reads in greyscale as an arrow pointing left.
Dot plot with range
`variant="dots"` — N `valueKeys` per row as dots on the shared axis (Datawrapper's dot plot), `range` drawing a bar between each row's extremes; the colour key outside the plot lists the three keys.
Legend, placement and hover
`legend` replaces the always-on corner colour key above with the shared container-legend engine — same three rows, now placement-aware and with a real hover: pointing at a row dims every OTHER key's dots, on every row.
API
DumbbellChart
| Prop | Type | Description |
|---|---|---|
messages | ChartMessages | messages group (RM-187): this chart's own words, keyed by the ui catalogue's `charts.*` message keys. A key set here wins over the `LocaleProvider`; every other key reads the catalogue as before. |
datarequired | Record<string, unknown>[] | Data array — one row per category. |
categoryrequired | string | Key in `data` for the category label. |
startKeyrequired | string | Key in `data` for the "before" value. |
endKeyrequired | string | Key in `data` for the "after" value. |
orientation | DumbbellOrientation | Rows (default) or columns. Ignored by `variant="slope"`, which is always two columns. |
variant | DumbbellVariant | `"dumbbell"` (default, one track per category) or `"slope"` (two columns, one line per category). |
beads | DumbbellBeadsConfig | F12: draws a countable `UnitStack` between the two markers — one dot per `unit` of `|end - start|`, seeded jitter so each row draws differently. Ignored by `variant="slope"`. |
markers | DumbbellMarkerStyle | Marker fill style. Default `{ start: "hollow", end: "filled" }` (the F12 "before/after" read). |
extraKeys | string[] | Extra numeric keys (e.g. competitor values) drawn as small dots on the same track. Ignored by `variant="slope"`. |
valueKeys | string[] | `variant="dots"` only: N numeric keys drawn as one dot per key on the shared value axis, instead of the two-marker `start`/`end` read — `startKey`/`endKey` still name the first/last of them (for the domain and the optional `range` bar), and any keys between are the "extra" dots. |
range | boolean | `variant="dots"` only: draws a bar between the row's lowest and highest dot. Default `false`. |
keyColors | Readonly<Record<string, string>> | `variant="dots"` only: pin a value key to a colour (`{ Target: "var(--chart-mono-2)" }`) — dots and the colour key follow. Keys left out keep their palette colour. Default: none. |
arrowWidth | number | `variant="arrow"` only: the arrow head's base width in px. Default `8`. |
groupBy | string | Buckets rows by this column, rendering a header + separator before each group (same shape as `BarChart`'s `groupBy`, RM-113/RM-116). Groups keep each row's resolved sort order; group order is first-seen in that same order. Unset (default) draws no headers — byte-identical to today. |
showDelta | boolean | Show a signed delta label (`HaloText`) at the end marker. Default `false`. Superseded by `delta` when set. |
deltaLabelFormat | (delta: number, row: DumbbellRow) => string | Custom formatter for the `showDelta` label — receives the signed delta and its row, returns the full string (including sign/unit). Unset (default) keeps today's rendering: a bare `"+"` prefix on non-negative deltas ahead of `formatValue(delta)`. Use this when a delta needs a unit suffix (e.g. `"pp"`) or a true minus sign the active `valueFormat`/locale doesn't give. Superseded by `delta.format` when `delta` is set. |
delta | DumbbellDeltaConfig | Replaces/aliases `showDelta` + `deltaLabelFormat` (RM-116): one object, `mode` picking an absolute or a `%` delta. Wins over both when set. |
bothEndsLabeled | boolean | `variant="slope"` only: label the END of each line with its category name too (`"{category} {value}"`, matching the START label), not just the bare value. Default `false` (byte-identical: end label stays value-only). |
valueLabelFormat | (value: number, row: DumbbellRow) => string | `variant="slope"` only: custom formatter for the plain VALUE half of each end's label — receives the raw value and its row, returns just the value text (the chart still prepends `"{category} "` itself). Unset (default) keeps `formatValue` (the active `valueFormat`). `Intl` never pads a whole number, so an exact-integer reading (`92`) needs this to force a fixed decimal count (`"92.0"`) matching its neighbours' precision. |
referenceLine | { value: number; label: string } | `orientation="horizontal"` (dumbbell variant) only: one labelled vertical reference line at `value` on the shared value scale (e.g. an industry benchmark). Unset (default) draws nothing. |
showValueAxis | boolean | `orientation="horizontal"` (dumbbell variant) only: draws light tick marks + value labels along the bottom of the plot, from the same value scale. Default `false` (byte-identical: no axis). Superseded by `valueAxis` when set. |
valueAxis | DumbbellValueAxisConfig | A richer value axis than `showValueAxis` — position (top/bottom) and domain rounding. Setting this also turns the axis on. See {@link DumbbellValueAxisConfig}. |
sortBy | DumbbellSortBy | Sort rows before rendering. `"delta"` sorts **descending by `|delta|`** (magnitude, sign ignored — the biggest mover first, whether it's an increase or a decrease); `"deltaPercent"` is the same magnitude-first read for `delta / start`; `"start"`/`"end"`/`"label"` sort **ascending**; `"data"` is an explicit spelling of `"none"` (spreadsheet order). Default `"none"`. |
reverse | boolean | Reverses the order `sortBy` resolves to. Default `false`. |
palette | ChartPalette | Which colour family rows draw from. Default `"categorical"`. |
rowColor | (row: DumbbellRow, index: number) => string | undefined | Per-row colour override — return a `var(--…)` token to recolour that row's connector/markers/labels, or `undefined` to keep the row on the resolved `palette`. The one seam an "argument, not a chart" infographic needs to emphasise a row or two (a status tone) while the rest stay on the shared palette — never a second palette, since most rows should read as unremarkable, not as a competing category. Unset (default) is byte-identical to today's per-row palette colouring. |
valueFormat | ChartValueFormat | How displayed numbers (the delta label) are formatted. Default `"compact"`. |
margin | number | Partial<Margin> | Chart margins: one number for every side, or per side. |
aspectRatio | string | Aspect ratio as `"width / height"`. Default `"2 / 1"`. |
plotHeight | Responsive<ChartPlotHeight> | The plot's own height (ADR 0039): px, or `{ aspect }` (width ÷ height), optionally per breakpoint. Wins over `aspectRatio`, which stays an alias. |
className | string | |
status | ChartStatus | Loading vs ready (RM-185). `"loading"` shows a skeleton in the plot box the chart will fill, with one polite status message, until the data is ready. Default: `"ready"`. |
accessibleLabel | ChartA11yProps["accessibleLabel"] | Accessible name for the chart region (announces to AT on focus). |
accessibleDescription | ChartA11yProps["accessibleDescription"] | Supplemental description read by AT (e.g. category count + value range). |
annotations | readonly ChartAnnotation[] | Declarative annotations in data units (RM-111): text notes, ranges, reference lines and row notes. Ranges paint under the series, the rest over them; at the `narrow` tier each text note becomes a numbered marker listed in a key under the plot, and every annotation is restated once in the figure description. A row note tracks its row by category, so it still lands on the right row after `sortBy`/`groupBy` reorders it. |
analytics | readonly ChartAnalytic[] | Statistical lines and bands computed from `data` (RM-138, ADR 0040 §1) — an average, a median, a percentile band — on the value axis, drawn through the annotation layer. `of` defaults to `startKey`; `"all"` pools both ends. |
legend | ContainerLegendProp | Renders a legend (RM-118). `variant="dots"` draws one row per `valueKeys` entry, via `useContainerLegend` (placement + hover only, R3: hovering/focusing a row dims every OTHER dot key's dots, on every row, never hides one — a dumbbell row is a CATEGORY, not a series, so there is nothing per-key to hide). REPLACES the pre-existing, unconditional corner dot-key badge (`data-slot="dumbbell-chart-dot-legend"`, RM-116) when set; unset (default) keeps that badge exactly as it always rendered — it predates the shared legend engine and R1 ("unset renders nothing new") only governs what THIS prop adds, not existing UI. `"dumbbell"`/`"slope"`/`"arrow"` (#610) draw a two-entry start/end legend instead — each row IS its own category (already labelled beside its own mark), so there is no per-CATEGORY key to legend; what these three variants share across every row is the marker SHAPE at each end (`markers`, default hollow start / filled end), so the legend keys THAT: one hollow-swatch entry (`startLabel ?? startKey`), one filled-swatch entry (`endLabel ?? endKey`). Both entries share one neutral ink (`chartCssVars.foreground`) — shape, not colour, is what they teach, since each row already draws in its own category colour. `{ values: true }` prints each entry's column total over every row (the start and end columns, or each `valueKeys` column), in `valueFormat`. |
startLabel | string | `legend`'s start-marker entry label, two-marker variants only. Default: `startKey`. |
endLabel | string | `legend`'s end-marker entry label, two-marker variants only. Default: `endKey`. |
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. |
HaloText
| Prop | Type | Description |
|---|---|---|
halo | string | The halo colour. Defaults to `var(--chart-background)` — the plot ground — which is what makes the mark THEME-SAFE: on a light card the halo is light, on a dark card it is dark, with no `dark:` override and no literal anywhere. Override it only when the text sits on something other than the plot ground (a tooltip plate, a filled zone) — and then with another semantic token. The halo tracks the PLOT ground, not the card. The shipped themes declare that mirror in the token layer (`--chart-background: var(--card)` in `themes.css`), so the two agree by contract, not by coincidence. A theme that paints the plot on something other than `--card` must set `--chart-background` to that ground, or every halo punches a card-coloured hole in it. |
haloWidth | number | Halo width in px (default 3). The visible halo is half of it — a stroke is centred. |
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). |
Theming
The tokens each state resolves to. Change the token and every theme follows.
| State | Token |
|---|---|
| track | 0.6px var(--chart-grid) hairline per row — furniture, independent of the row's colour |
| markers | row colour from resolvePalette (--chart-1…--chart-6 categorical, falling back to mono past 6 rows) |
| beads | beads.unit draws a UnitStack in var(--chart-foreground) — a neutral ink, NOT the row's colour, by design |
| labels | category label in var(--chart-label); the signed delta label in var(--chart-foreground) via HaloText |