Before and after for many things at once: two dots on one scale with a line between, colored by whether the change is good. Every row also says it in words, such as up 8, better.
Refund questions
71% → 86%up 15%, better
Order status
92% → 94%up 2%, better
Account access
88% → 81%down 7%, worse
Billing disputes
54% → 69%up 15%, better
Product advice
63% → 63%no change
"use client"
import { DumbbellChart } from "@/components/ballmac/dumbbell-chart"
const rows = [
{ id: "1", label: "Refund questions", before: 71, after: 86 },
{ id: "2", label: "Order status", before: 92, after: 94 },
{ id: "3", label: "Account access", before: 88, after: 81 },
{ id: "4", label: "Billing disputes", before: 54, after: 69 },
{ id: "5", label: "Product advice", before: 63, after: 63 },
]
export default function DumbbellChartDemo() {
return (
<div className="w-full max-w-2xl">
<DumbbellChart title="Answers rated helpful, by topic" rows={rows} beforeLabel="Prompt v14" afterLabel="Prompt v15" format={(v) => `${v}%`} />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/dumbbell-chartAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/dumbbell-chart.tsx// Ballmac UI: Dumbbell Chart. https://ui.ballmac.com/components/dumbbell-chart "use client" import * as React from "react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type DumbbellRow = { id: string label: string before: number after: number } type DumbbellChartProps = Omit<React.ComponentProps<"figure">, "children" | "title"> & { rows: DumbbellRow[] /** What the first dot is, for example "Last version". */ beforeLabel?: string /** What the second dot is, for example "This version". */ afterLabel?: string /** True when a smaller number is better, for example latency or cost. */ lowerIsBetter?: boolean format?: (value: number) => string title?: string } const nf = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 }) /** * Before and after for many things at once: each row is two dots on one scale with a line between them, so how far it moved and which way is the first thing you * see. Every row also says it in words ("up 8, better"), because the colors only repeat it. */ function DumbbellChart({ rows, beforeLabel, afterLabel, lowerIsBetter = false, format = (v) => nf.format(v), title, className, ...props }: DumbbellChartProps) { const msg = useMessages() const id = React.useId() beforeLabel ??= msg("dumbbell-chart.before", "Before") afterLabel ??= msg("dumbbell-chart.after", "After") const all = rows.flatMap((r) => [r.before, r.after]) const lo = Math.min(...all) const hi = Math.max(...all) const pad = (hi - lo || 1) * 0.08 const min = lo - pad const span = hi + pad - min const at = (v: number) => ((v - min) / span) * 100 return ( <figure data-slot="dumbbell-chart" aria-labelledby={title ? `${id}-t` : undefined} className={cn("@container grid min-w-0 gap-4", className)} {...props}> {title && <figcaption id={`${id}-t`} className="text-sm font-medium">{title}</figcaption>} <p className="flex flex-wrap gap-x-5 gap-y-1 text-xs text-muted-foreground" aria-hidden="true"> <span className="flex items-center gap-1.5"><span className="size-3 rounded-full border-2 border-muted-foreground bg-card" />{beforeLabel}</span> <span className="flex items-center gap-1.5"><span className="size-3 rounded-full bg-foreground" />{afterLabel}</span> </p> <ul className="grid grid-cols-[minmax(0,1fr)] gap-3.5"> {rows.map((r) => { const delta = r.after - r.before const better = delta === 0 ? null : lowerIsBetter ? delta < 0 : delta > 0 const a = at(r.before) const b = at(r.after) const word = delta === 0 ? msg("dumbbell-chart.same", "no change") : msg(delta > 0 ? "dumbbell-chart.up" : "dumbbell-chart.down", delta > 0 ? "up {amount}" : "down {amount}", { amount: format(Math.abs(delta)) }) return ( <li key={r.id} className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-3 gap-y-1 @xl:grid-cols-[9rem_minmax(0,1fr)_11.5rem] @xl:items-center @xl:gap-4"> <p className="order-1 truncate text-sm font-medium">{r.label}</p> <div className="relative order-3 col-span-2 h-6 @xl:order-2 @xl:col-span-1" aria-hidden="true"> <span className="absolute inset-x-0 top-1/2 h-px bg-border" /> <span className={cn("absolute top-1/2 h-[3px] -translate-y-1/2 rounded-full", better === null ? "bg-muted-foreground/40" : better ? "bg-chart-2" : "bg-destructive")} style={{ insetInlineStart: `${Math.min(a, b)}%`, width: `${Math.abs(b - a)}%` }} /> <span className="absolute top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-muted-foreground bg-card rtl:translate-x-1/2" style={{ insetInlineStart: `${a}%` }} /> <span className="absolute top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground ring-2 ring-card rtl:translate-x-1/2" style={{ insetInlineStart: `${b}%` }} /> </div> <p className="order-2 text-end text-xs tabular-nums text-muted-foreground @xl:order-3"> <span className="font-medium text-foreground">{format(r.before)} → {format(r.after)}</span> <span className="ms-2 @xl:ms-2">{word}{better === null ? "" : better ? msg("dumbbell-chart.better", ", better") : msg("dumbbell-chart.worse", ", worse")}</span> </p> </li> ) })} </ul> </figure> ) } export { DumbbellChart, type DumbbellChartProps, type DumbbellRow }Update the import paths to match your project setup.
Usage
import { DumbbellChart } from "@/components/ballmac/dumbbell-chart"The full example is in the Code tab above.
Examples
Response time, lower is better
Search
420 ms → 310 msdown 110 ms, better
Checkout
280 ms → 340 msup 60 ms, worse
Login
190 ms → 185 msdown 5 ms, better
"use client"
import { DumbbellChart } from "@/components/ballmac/dumbbell-chart"
export default function DumbbellChartLower() {
return (
<div className="w-full max-w-2xl">
<DumbbellChart title="Response time" lowerIsBetter beforeLabel="Last month" afterLabel="This month" format={(v) => `${v} ms`} rows={[{ id: "a", label: "Search", before: 420, after: 310 }, { id: "b", label: "Checkout", before: 280, after: 340 }, { id: "c", label: "Login", before: 190, after: 185 }]} />
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
rows* | DumbbellRow[] | — |
beforeLabelWhat the first dot is, for example "Last version". | string | — |
afterLabelWhat the second dot is, for example "This version". | string | — |
lowerIsBetterTrue when a smaller number is better, for example latency or cost. | boolean | false |
format | (value: number) => string | (v) => nf.format(v) |
title | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Rows | Each row's change is text with better or worse |
| Chart | The dots and line are decoration |
| Legend | Named dots above the rows |
Use with AI
rows is [{ id, label, before, after }]. beforeLabel and afterLabel name the dots; lowerIsBetter flips which direction is good; format(value); title. Each row writes 'before → after' and 'up N, better' or 'down N, worse' or 'no change'; the line color only repeats it. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Dumbbell Chart (@ballmac/dumbbell-chart) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Comparing two versions, periods or models across several measures
- Benchmarks and A/B results
Not for
- Change over many points in time; use chart-line
- One number against a target; use bullet-chart
Registry JSON: https://ui.ballmac.com/r/dumbbell-chart.json
Credits
Free to use in personal and commercial projects.
- npm
- None
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Bar List
A ranked list of bars with the name and number on top of the bar, so both stay readable at any width. Rows can be links or buttons, a long list folds to its top few, and every value is real text.
Eval Scorecard
The result of testing a model or prompt against a set of cases: pass rate and change from the baseline in words, a trend, what got worse or was fixed, and every case opened to show the question, a good answer and the model's.
Model Compare
Models side by side, with intelligence, speed and cost as meters that also have a spoken value, context size, a Recommended mark, search, provider filters, and a plain reason when a model is not available.
Stat Card
A metric card with a clear comparison trend, optional visual, and readable value hierarchy.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.