Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildDumbbellLayout,
  inspectDumbbellData,
  type ChartDumbbellData,
  type ChartDumbbellItem,
  type DumbbellDirection,
  type DumbbellRow,
  type DumbbellSort,
  type DumbbellValence,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-dumbbell.json

Prompt

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 directiondelta says which way it moved, higherIsBetter says 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-label carries 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.

On This Page