Dumbbell Chart
A four-state dumbbell chart — one row per category, two dots joined by a bar on one shared scale, ranked by valence-aware change with direction carried by shape, arrow and row order as well as colour.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-dumbbell.jsonPrompt
Build a React + TypeScript + Tailwind "ChartDumbbell" card in plain SVG (no
chart library) with zod. It is a ranked list, not a plot: one row per
category, two dots joined by a bar, ordered by change.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready";
title: string; caption?: string;
start: { label: string }; end: { label: string };
unit?: string; higherIsBetter?: boolean; // defaults to true
items: { id: string; label: string; start: number; end: number }[] }
- The unit is ONE field at the top level, not one per end. A dumbbell measures
a single metric twice, so the two ends cannot be in different units; there
is nothing to disagree about and nothing to check. (A slope chart models the
unit per end for the opposite reason — there, a per-end unit is what lets
"revenue in $ then revenue in €" be refused at parse time.)
- superRefine refuses at parse time: a ready chart with zero rows (that state
is called "empty"), duplicate ids, and non-finite values.
- Component props = z.infer of the schema plus sort (default "improvement"),
rowHeight (34, clamped 22-72), labelWidth (132, clamped 72-320),
valueLabels: "auto" | "always" | "never" (default "auto"), formatValue,
onSelectItem, onRetry and className.
- Ship a pure geometry module beside the schema, so none of this maths lives
in the component: inspectDumbbellData() for the structural pass, niceTicks()
for the gridlines, and buildDumbbellLayout() returning rows carrying delta,
change, direction, valence, rank, startX and endX, plus min, max, flatScale,
ticks, zeroX, counts and the best and worst rows.
Behavior
- ONE SHARED SCALE. The domain is fitted to the min and max of every start and
every end across all rows, and mapped linearly into [x0, x1]. Sharing it is
the whole point: it is what makes a bar in row 1 comparable with a bar in
row 9, and it is the difference from a bullet chart, where every row gets
its own domain and lengths across rows mean nothing. Do NOT pad the domain
to make room for the dots — take the dot radius out of the pixel band
instead (x0 = plotLeft + gutter + r). Padding the domain would move the
printed min and max away from the real ones to buy a few px of clearance.
- VALENCE, NOT SIGN, drives colour and the default order. delta = end - start;
improvement = higherIsBetter ? delta : -delta. On latency, cost or churn a
fall is the good news, so a chart that colours by sign is wrong half the
time. Direction (up / down / flat) is kept as a separate fact from valence
(good / bad / neutral) and is carried by its own channel.
- SORT is a first-class prop because ordering is a second direction channel:
with the best news at the top, "did this improve?" is readable from position
alone. Modes: "improvement" (default), "decline", "magnitude" (|delta|),
"end", "start", "label", "none" (contract order). Sort with a stable sort so
ties stay in contract order and two renders of one feed are identical, then
assign rank = index + 1 and put that rank in the readout and the table.
- GRIDLINES: niceTicks(min, max, 4) picks the smallest 1 / 2 / 5 x 10^n step
that is not below span / 4, so the tick count never exceeds 5. Generate them
as first + i * step, never by repeated addition — accumulating a float step
drifts (0.1 + 0.1 + 0.1 != 0.3) and a gridline 4e-16 off its own label is a
gridline in the wrong place. The domain stays fitted, so the first and last
tick sit inside the plot rather than at its edges; that is the deliberate
trade for not shortening every bar to buy rounder end labels.
- ZERO RULE: when min < 0 < max, draw a dashed rule at x(0) and drop any
gridline within 0.5px of it. Without it "improved by 10 points" and "became
profitable" draw the same bar.
- DEGENERATE DATA, each handled on purpose:
* zero rows — render the empty branch even when status is "ready"; a
caller that did not read the contract still gets the right picture.
* one row — everything holds; the arrow-key hint drops out because there
is nowhere to move.
* every value identical — span is 0, so a linear scale divides by zero.
Centre the whole column, say so in the summary, and print the one value
under the plot instead of ticks. Pinning them to x0 would imply "all at
the minimum of something".
* start == end on one row — a zero-length bar paints as a lone dot, which
reads as one measurement rather than two that agree. Draw a ringed
target (outer ring + small centre dot) and the flat glyph instead.
* start == 0 — the relative change is undefined, not 0% and not infinity.
Print the absolute delta, omit the percent line, and say "undefined
(started at zero)" in the table.
* values wider than the space — see the lane budget below.
- FOUR STATES are first-class branches of one bg-card panel: a pulsing
six-bar skeleton (aria-hidden, with an sr-only role="status" line), an empty
state naming both endpoint labels, an error state showing either the
transport message or the specific structural issue plus a "Try again"
button only when onRetry exists, and ready.
- KEYBOARD: the rows share ONE roving tab stop. Tab enters at the last focused
row; ArrowUp / ArrowDown move; Home / End jump; Enter and Space activate
onSelectItem when it exists (preventDefault on Space, or the page scrolls
out from under the chart). Handle keydown with one delegated listener on the
<svg> and read the index off the target's data-row attribute — one handler
instead of one per row. Move by calling .focus() on the target row, which is
already focusable at tabIndex -1; the focus event is what updates the roving
stop, so a move never waits for a render.
- READOUT: one line under the plot shows the active row's whole sentence,
where "active" is whichever of pointer and focus moved last. Pointer leaving
hands the readout back to the focused row rather than blanking it. The line
is aria-hidden: the focused row already announces that exact sentence, and a
live region would say all of it twice.
- CLEANUP: the only subscription is one ResizeObserver, attached through a
CALLBACK REF rather than useRef + useEffect — the plot only exists in the
ready branch, so an effect that ran once on mount would observe null forever
on a card that starts in loading. Commit the measurement inside
requestAnimationFrame (a synchronous setState from the callback is what
produces "ResizeObserver loop completed with undelivered notifications"),
and disconnect the observer plus cancel the pending frame on node
replacement and on unmount.
Rendering & styling
- COLOUR IS NEVER ALONE. Four channels carry the same fact:
shape — hollow ring = the start reading, filled disc = the end reading.
Which one is on the left depends on the data, so left/right can
never be allowed to mean before/after.
glyph — an 8x8 triangle in the delta lane, up / down / flat bar.
order — the default sort puts improvements above declines.
colour — var(--chart-2) for good, var(--chart-5) for bad,
var(--muted-foreground) for unchanged. These are the two ends of
the palette that differ in hue and lightness and both clear 3:1
against the card in either theme.
Everything else is a semantic token: bg-card, fill-card, fill-muted,
stroke-border, stroke-muted-foreground, stroke-ring, text-muted-foreground.
No hex, no oklch, no invented hue.
- LANE BUDGET, all three lanes sized from the widest string they must hold:
delta lane = clamp(chars * 7.2 + 36, 56, 140)
label lane = the labelWidth prop, clamped against the container so the
plot never falls under 96px
value gutter = clamp(chars * 6.6 + 10, 24, 120), one per side
A tabular-nums digit is ~0.6em, so 6.6px at 11px and 7.2px at 12px. Use the
estimate ONLY to reserve room, never to place a glyph: every label is laid
out by the browser inside a foreignObject with CSS truncate, which is the
only thing that measures text correctly (a JS estimate is off by -29% on
all-caps runs and +30% on digits). Compute the minimum total width AFTER the
delta lane (72 + deltaLane + 96); a floor that ignores a widened lane puts
plotRight left of plotLeft on a narrow card and paints every bar backwards.
- THE TWO VALUE LABELS GROW OUTWARD, away from each other, so they can never
collide however short the bar is — the only thing left to reserve is the
gutter outside the pair. "auto" prints them while there are at most 10 rows
AND the widest value fits its gutter whole: a truncated "1,2…" is worse than
no number, because the readout and the table already hold the exact figure.
- Rows: rowHeight px each, a rounded highlight rect per row that doubles as
the hit target (opacity 0 still hit-tests, so one rect does both jobs), the
bar as a round-capped line at 6px (8px while active), and the delta lane
with the signed change on line 1 and the relative change on line 2 — line 2
is dropped below rowHeight 30 rather than clipped.
- Every foreignObject is pointer-events:none. An HTML box hit-tests its whole
rectangle even with no background, so a label lane would otherwise swallow
every hover meant for the row band behind it.
- RESPONSIVE: measure the wrapper and set viewBox to exactly the width the
geometry was computed for. Then the one frame before the observer reports
(and the server's render) is drawn scaled to fit rather than clipped, and
once the real width arrives the viewBox equals the viewport, the scale is
exactly 1 and the text is at its stated px size. Height is explicit
(8 + rows * rowHeight + 26), so the card cannot collapse in a flex parent.
- ARIA: the <svg> is role="group" with an aria-label carrying the finding —
count, how many rose / fell / were unchanged, the biggest improvement and
the biggest decline with real numbers, the domain, and the ordering. NOT
role="img": that is children-presentational and would turn every row into an
unreachable dead tab stop. Each row is role="img" (role="button" when
onSelectItem exists) with an aria-label holding its own sentence: both
values, the movement, the relative change and "row N of M". Under the plot,
an sr-only WRAPPER DIV holds a real table (rank, category, both values,
change, relative, direction) — sr-only on the wrapper, never on the table,
because CSS width is only a lower bound for a table box and width:1px does
not hold one back (a 375px viewport picks up hundreds of px of scroll).
- FOCUS RING: draw your own rect, revealed by group-focus-visible, because the
native ring on a focusable <g> is drawn inconsistently across browsers.
Driven by the browser's own :focus-visible heuristic, so a mouse click
highlights a row without ringing it.
- MOTION: the only animations are the highlight fade
(transition-opacity duration-150 motion-reduce:transition-none) and the
skeleton pulse (motion-reduce:animate-none). With motion off the chart is
fully functional; nothing is animated that carries information.
Customization levers
- sort: "improvement" while the story is the change; "end" or "start" when
"who is biggest now" matters more; "label" for a lookup list; "none" when
the caller already ranked the rows. It only reorders — no number moves.
- rowHeight + labelWidth: 34/132 is the comfortable default; 24/168 turns the
card into a 24-row ranking where long names still survive; 48 gives a
five-row executive card room to breathe.
- valueLabels: "never" hands the whole width to the bars for a dense card,
"always" keeps the numbers on a wide card even when they have to squeeze.
- formatValue: currency, compact notation ("1.2M"), or a per-locale
Intl.NumberFormat. It feeds the dots, the delta lane, the axis, the readout
and the table together, so they can never disagree.
- higherIsBetter: flip it for latency, cost, churn or defect counts and the
colour, the arrow legend and the default order all follow.
- onSelectItem: wire it to drill into a category and rows become real buttons;
leave it off and they stay readable but claim no action — no fake
affordance. onRetry works the same way in the error branch.
- Palette: re-point the two valence tokens (e.g. --chart-1 / --chart-4) and
bars, glyphs and legend follow together. Because shape, glyph and order
already carry direction, a single-hue palette still reads correctly.
- Density knobs that change nothing else: the 4-tick target, the 6px bar
width, the 5px dot radius, and dropping the relative-change line to make
every row one line.Concepts
- Shared value scale — every row is measured against one domain fitted to all the data, so a long bar really is a big change wherever it sits in the list. This is exactly what a bullet chart gives up when it hands each row its own domain, and it is what lets the eye rank the rows without reading a single number.
- Valence vs direction —
deltasays which way it moved,higherIsBettersays whether that is good. The two are kept apart on purpose: colour follows valence, the arrow glyph follows direction, and on a latency chart the two disagree on every row. - Direction without colour — hollow dot for the start reading, filled dot for the end, an up/down/flat triangle in the delta lane, and improvements sorted to the top. Any one of the four survives greyscale, colour blindness and a bad projector; colour is the redundant channel here, not the load-bearing one.
- Outward value labels — each dot's number is printed on the side facing away from its partner, so two labels in one row can never meet however short the bar is. That reduces label fitting from a collision problem to a budget problem: reserve one gutter per side, and print the numbers only when they fit whole.
- Unchanged marker — a row whose two readings agree has a zero-length bar, which would paint as a lone dot and read as a single measurement. The ringed target says "both ends are here, and they agree" without spending any colour on it.
- Roving tab stop — the whole chart is one tab stop, not N. Arrow keys move focus between rows, each row's
aria-labelcarries its own sentence, and the visible readout mirrors whichever of pointer or focus moved last so sighted keyboard users and screen-reader users read the same thing.
Marimekko Chart
A four-state mosaic chart where column width is each column's share of the total and stacked height is its internal mix, so a cell's area is its share of everything — with a minimum-width floor, texture tiers past the fifth band, and labels that drop out cell by cell.
Streamgraph
A four-state streamgraph whose bands stack on a wiggle-minimising baseline — inside-out ordering, overshoot-free monotone curves, a per-column readout and an sr-only series-by-tick table.