A waterfall or bridge chart from a start to an end: floating bars for increases and decreases with a + or − sign as well as colour, totals on the baseline, connectors, real HTML labels and a table of the numbers for screen readers.
| Step | Change | Total after |
|---|---|---|
| September | Total | $82.4k |
| New customers | +$9.6k, increase | $92.0k |
| Upgrades | +$4.1k, increase | $96.1k |
| Downgrades | −$2.3k, decrease | $93.8k |
| Cancelled | −$5.2k, decrease | $88.6k |
| October | Total | $88.6k |
Ends at $88.6k.
"use client"
import { WaterfallChart } from "@/components/ballmac/waterfall-chart"
export default function WaterfallChartDemo() {
return (
<div className="w-full max-w-3xl">
<WaterfallChart
title="Monthly recurring revenue, September to October"
format={(v) => `$${(v / 1000).toFixed(1)}k`}
steps={[
{ id: "start", label: "September", value: 82400, kind: "total" },
{ id: "new", label: "New customers", value: 9600 },
{ id: "exp", label: "Upgrades", value: 4100 },
{ id: "down", label: "Downgrades", value: -2300 },
{ id: "churn", label: "Cancelled", value: -5200 },
{ id: "end", label: "October", value: 88600, kind: "total" },
]}
/>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/waterfall-chartAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/waterfall-chart.tsx// Ballmac UI: Waterfall Chart. https://ui.ballmac.com/components/waterfall-chart "use client" import * as React from "react" import { useMessages } from "@/lib/ballmac/i18n" import { cn } from "@/lib/utils" type WaterfallStep = { id: string label: string /** For a change, how much it adds (positive) or takes away (negative). For a total, the running total at that point. */ value: number /** A total stands on the baseline and resets the running total. A change floats from where the last step ended. */ kind?: "change" | "total" } type WaterfallChartProps = Omit<React.ComponentProps<"figure">, "children"> & { steps: WaterfallStep[] /** Turns a value into text. */ format?: (value: number) => string /** Height of the plot in pixels. */ height?: number /** A heading read by assistive technology and shown above the chart. */ title?: string } type Bar = { step: WaterfallStep; from: number; to: number; running: number } /** The running total, the start and end of every bar. Pure, so it can be tested and reused. */ function buildBars(steps: WaterfallStep[]): Bar[] { let running = 0 return steps.map((step) => { if (step.kind === "total") { running = step.value return { step, from: 0, to: step.value, running } } const from = running running += step.value return { step, from, to: running, running } }) } const sign = (n: number) => (n > 0 ? "+" : n < 0 ? "−" : "") /** * A waterfall (bridge) chart: a start, the changes that add to it or take away from it, and where it ends. Built from HTML, so labels are real text that wraps * and scales, and a table of the same numbers is there for screen readers. Increases and decreases carry a + or − sign as well as their colour. * Runs on the server. */ function WaterfallChart({ steps, format = (v) => v.toLocaleString("en-US"), height = 260, title, className, ...props }: WaterfallChartProps) { const id = React.useId() const msg = useMessages() const bars = buildBars(steps) const all = bars.flatMap((b) => [b.from, b.to, 0]) const lo = Math.min(...all) const hi = Math.max(...all) const span = hi - lo || 1 const y = (v: number) => ((hi - v) / span) * 100 const zero = y(0) const last = bars[bars.length - 1] return ( <figure data-slot="waterfall-chart" aria-labelledby={title ? `${id}-t` : undefined} className={cn("grid w-full min-w-0 gap-3", className)} {...props}> {title && ( <figcaption id={`${id}-t`} className="text-sm font-medium text-foreground"> {title} </figcaption> )} <div className="rounded-xl border bg-card p-3 pb-2 sm:p-4" dir="ltr"> <div className="relative flex gap-1.5 sm:gap-2.5" style={{ height }} aria-hidden="true"> <span className="absolute inset-x-0 border-t border-dashed border-border" style={{ top: `${zero}%` }} /> {bars.map((b, i) => { const top = y(Math.max(b.from, b.to)) const h = Math.max(Math.abs(y(b.to) - y(b.from)), 0.8) const total = b.step.kind === "total" const up = b.step.value >= 0 const next = bars[i + 1] return ( <div key={b.step.id} className="relative min-w-0 flex-1"> <span className={cn("absolute inset-x-0 rounded-[5px] transition-[height,top] duration-500 motion-reduce:transition-none", total ? "bg-foreground" : up ? "bg-chart-2" : "bg-destructive")} style={{ top: `${top}%`, height: `${h}%` }} /> <span className="absolute inset-x-0 -translate-y-full pb-1 text-center text-[11px] font-medium tabular-nums text-foreground sm:text-xs" style={{ top: `${top}%` }}> {total ? format(b.step.value) : `${sign(b.step.value)}${format(Math.abs(b.step.value))}`} </span> {next && <span className="absolute h-px bg-border" style={{ top: `${y(b.to)}%`, insetInlineStart: "100%", width: "0.4rem" }} />} </div> ) })} </div> <div className="mt-2 flex gap-1.5 sm:gap-2.5" aria-hidden="true"> {bars.map((b) => ( <p key={b.step.id} className="min-w-0 flex-1 text-center text-[11px] leading-tight text-muted-foreground sm:text-xs"> {b.step.label} </p> ))} </div> </div> <table className="sr-only"> <caption>{title ?? "Changes from the start to the end"}</caption> <thead> <tr> <th scope="col">{msg("waterfall-chart.step", "Step")}</th> <th scope="col">{msg("waterfall-chart.change", "Change")}</th> <th scope="col">{msg("waterfall-chart.after", "Total after")}</th> </tr> </thead> <tbody> {bars.map((b) => ( <tr key={b.step.id}> <th scope="row">{b.step.label}</th> <td>{b.step.kind === "total" ? "Total" : `${sign(b.step.value)}${format(Math.abs(b.step.value))}${b.step.value >= 0 ? ", increase" : ", decrease"}`}</td> <td>{format(b.running)}</td> </tr> ))} </tbody> </table> {last && ( <p className="sr-only"> {msg("waterfall-chart.ends", "Ends at {value}.", { value: format(last.running) })} </p> )} </figure> ) } export { WaterfallChart, buildBars, type WaterfallChartProps, type WaterfallStep }Update the import paths to match your project setup.
Usage
import { WaterfallChart, buildBars } from "@/components/ballmac/waterfall-chart"The full example is in the Code tab above.
Examples
Crossing zero
| Step | Change | Total after |
|---|---|---|
| Opening cash | Total | $120k |
| Sales | +$310k, increase | $430k |
| Payroll | −$240k, decrease | $190k |
| Suppliers | −$210k, decrease | −$20k |
| Tax | −$60k, decrease | −$80k |
| Loan drawn | +$50k, increase | −$30k |
| Closing cash | Total | −$30k |
Ends at −$30k.
"use client"
import { WaterfallChart } from "@/components/ballmac/waterfall-chart"
export default function WaterfallChartNegative() {
return (
<div className="w-full max-w-3xl">
<WaterfallChart
title="Cash position over the quarter"
height={240}
format={(v) => `${v < 0 ? "−" : ""}$${Math.abs(v / 1000).toFixed(0)}k`}
steps={[
{ id: "open", label: "Opening cash", value: 120000, kind: "total" },
{ id: "sales", label: "Sales", value: 310000 },
{ id: "pay", label: "Payroll", value: -240000 },
{ id: "sup", label: "Suppliers", value: -210000 },
{ id: "tax", label: "Tax", value: -60000 },
{ id: "loan", label: "Loan drawn", value: 50000 },
{ id: "close", label: "Closing cash", value: -30000, kind: "total" },
]}
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
steps* | WaterfallStep[] | — |
formatTurns a value into text. | (value: number) => string | (v) => v.toLocaleString("en-US") |
heightHeight of the plot in pixels. | number | 260 |
titleA heading read by assistive technology and shown above the chart. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen readers | The bars are decoration; a table lists each step with its change (+ or − and increase or decrease) and the total after it |
| Signs | Increases and decreases are written with + and − on the bars, so colour is never the only signal |
Use with AI
steps is [{ id, label, value, kind?: 'change' | 'total' }]. A change floats from where the last step ended (positive is an increase, negative a decrease); a total sits on the baseline and resets the running total. format(value), height, title. Exports buildBars(steps). Server-rendered, no client code. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Waterfall Chart (@ballmac/waterfall-chart) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Explaining how a number moved from one value to another: revenue, headcount, cost
- A variance or bridge between two periods
Not for
- Showing a trend; use chart-line
- Parts of a whole; use treemap or chart-pie
- Steps with drop-off between stages; use flow-sankey
Registry JSON: https://ui.ballmac.com/r/waterfall-chart.json
Credits
Free to use in personal and commercial projects.
- npm
- None
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Stat Card
A metric card with a clear comparison trend, optional visual, and readable value hierarchy.
Bullet Chart
Stephen Few's bullet chart: a bar for the value, a mark for the target, three shades behind for poor, fair and good, and a sentence saying whether the goal was met. Many measures in a small space, server-rendered.
Chart Frame
The card around a Recharts chart: a Table view with CSV download, a legend that toggles series, patterns so series differ by more than color, a summary for screen readers, and loading, empty and error states.
Run Ledger
What an agent run cost, step by step: sortable time, tokens and money with each row's share, a total, the most expensive step marked, and an optional budget meter that warns in words.
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.