Skip to main content

DumbbellChart

Comparisonchart

Before/after (or this-year/last-year) per category — a track with two markers and the delta between them, so the CHANGE is the mark, not a second bar. variant="slope" swaps the per-category track for two shared value columns.

import { DumbbellChart } 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 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

Examples

16 live 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

PropTypeDescription
messagesChartMessagesmessages 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.
datarequiredRecord<string, unknown>[]Data array — one row per category.
categoryrequiredstringKey in `data` for the category label.
startKeyrequiredstringKey in `data` for the "before" value.
endKeyrequiredstringKey in `data` for the "after" value.
orientationDumbbellOrientationRows (default) or columns. Ignored by `variant="slope"`, which is always two columns.
variantDumbbellVariant`"dumbbell"` (default, one track per category) or `"slope"` (two columns, one line per category).
beadsDumbbellBeadsConfigF12: 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"`.
markersDumbbellMarkerStyleMarker fill style. Default `{ start: "hollow", end: "filled" }` (the F12 "before/after" read).
extraKeysstring[]Extra numeric keys (e.g. competitor values) drawn as small dots on the same track. Ignored by `variant="slope"`.
valueKeysstring[]`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.
rangeboolean`variant="dots"` only: draws a bar between the row's lowest and highest dot. Default `false`.
keyColorsReadonly<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.
arrowWidthnumber`variant="arrow"` only: the arrow head's base width in px. Default `8`.
groupBystringBuckets 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.
showDeltabooleanShow a signed delta label (`HaloText`) at the end marker. Default `false`. Superseded by `delta` when set.
deltaLabelFormat(delta: number, row: DumbbellRow) => stringCustom 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.
deltaDumbbellDeltaConfigReplaces/aliases `showDelta` + `deltaLabelFormat` (RM-116): one object, `mode` picking an absolute or a `%` delta. Wins over both when set.
bothEndsLabeledboolean`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.
showValueAxisboolean`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.
valueAxisDumbbellValueAxisConfigA richer value axis than `showValueAxis` — position (top/bottom) and domain rounding. Setting this also turns the axis on. See {@link DumbbellValueAxisConfig}.
sortByDumbbellSortBySort 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"`.
reversebooleanReverses the order `sortBy` resolves to. Default `false`.
paletteChartPaletteWhich colour family rows draw from. Default `"categorical"`.
rowColor(row: DumbbellRow, index: number) => string | undefinedPer-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.
valueFormatChartValueFormatHow displayed numbers (the delta label) are formatted. Default `"compact"`.
marginnumber | Partial<Margin>Chart margins: one number for every side, or per side.
aspectRatiostringAspect ratio as `"width / height"`. Default `"2 / 1"`.
plotHeightResponsive<ChartPlotHeight>The plot's own height (ADR 0039): px, or `{ aspect }` (width ÷ height), optionally per breakpoint. Wins over `aspectRatio`, which stays an alias.
classNamestring
statusChartStatusLoading 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"`.
accessibleLabelChartA11yProps["accessibleLabel"]Accessible name for the chart region (announces to AT on focus).
accessibleDescriptionChartA11yProps["accessibleDescription"]Supplemental description read by AT (e.g. category count + value range).
annotationsreadonly 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.
analyticsreadonly 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.
legendContainerLegendPropRenders 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`.
startLabelstring`legend`'s start-marker entry label, two-marker variants only. Default: `startKey`.
endLabelstring`legend`'s end-marker entry label, two-marker variants only. Default: `endKey`.
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.

Also accepts: ChartSelectionProps, ChartInteractionProps, FrameSizeGroupProps, Pick<ChartStateGroupProps, "status">

HaloText

PropTypeDescription
halostringThe 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.
haloWidthnumberHalo width in px (default 3). The visible halo is half of it — a stroke is centred.

Also accepts: SVGProps<SVGTextElement>

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" >

Theming

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

StateToken
track0.6px var(--chart-grid) hairline per row — furniture, independent of the row's colour
markersrow colour from resolvePalette (--chart-1…--chart-6 categorical, falling back to mono past 6 rows)
beadsbeads.unit draws a UnitStack in var(--chart-foreground) — a neutral ink, NOT the row's colour, by design
labelscategory label in var(--chart-label); the signed delta label in var(--chart-foreground) via HaloText