WaterfallChart
How a run of changes carried one total to another.
How a run of changes carried one total to another.
Each step's bar starts where the previous one ended, so the gap beneath it is the balance that step acted on and its length is what it changed by. A bar chart of the same numbers compares the changes against each other; this compares each of them against the running total, which is what makes it the chart for a revenue bridge, a cash-flow month or a variance between two budgets.
Steps marked total are anchored to the baseline and drawn in a neutral colour. They are readings rather than changes, and marking the first and last gives the run two ends to be a bridge between.
The order is yours and never the chart's. The steps are a sequence applied to a balance, so sorting them by size destroys the only thing the chart is asserting. For categories with no order to keep, use a BarChart; for stages that each contain the next, use a FunnelChart.
If the chart you need is not one of these, Plot is the same drawing machinery with nothing decided: you compose the marks over one measured box and one shared scale.
Installation
WaterfallChart ships with the library — no separate install.
import { WaterfallChart, type WaterfallDatum, waterfallSteps, Frame } from 'panelui-native';Or copy the source into your project, to own and edit it:
npx panelui-cli@latest add waterfall-chartUsage
<WaterfallChart data={bridge}>
<WaterfallChart.Header title="Closing" value="$541,600" legend />
<WaterfallChart.Grid />
<WaterfallChart.Connectors />
<WaterfallChart.Bars />
<WaterfallChart.XAxis />
<WaterfallChart.Tooltip />
</WaterfallChart>Composition
<WaterfallChart data={…}>
<WaterfallChart.Header /> {/* the strip above the plot */}
<WaterfallChart.Grid /> {/* lines across the value axis */}
<WaterfallChart.Skeleton /> {/* the stubs it waits behind */}
<WaterfallChart.Connectors /> {/* each bar's end to the next bar's start */}
<WaterfallChart.Bars /> {/* the bars themselves */}
<WaterfallChart.Values /> {/* each change, written at the end of its bar */}
<WaterfallChart.XAxis /> {/* step names along the bottom */}
<WaterfallChart.YAxis /> {/* value labels down the side */}
<WaterfallChart.Tooltip /> {/* the drag, and the card it opens */}
<WaterfallChart.Legend /> {/* a key to the three colours */}
</WaterfallChart>WaterfallChart.Header— The strip above the plot — what the run is of, what it reads, and room for a control or a key. The chart introducing itself, as distinct from the caption on the card around it.WaterfallChart.Grid— Lines across the value axis, so a bar can be read against a number rather than only against the bar beside it.WaterfallChart.Connectors— The lines from each bar's end to the next bar's start. Draw them beforeBarsso their ends pass behind the bars they touch rather than stopping short of them.WaterfallChart.Bars— The bars. Six animated paths a frame — one per colour, each split into the bar under the finger and the rest — so a run of forty steps costs what a run of four does.WaterfallChart.Values— Each step's change, written just past the end of its bar and signed. Upright only: sideways there is nowhere for a number at the end of a bar to go that is not on top of the bar or off the chart.WaterfallChart.Skeleton— The waiting state: equal stubs on the baseline with a sweep across them, shown whilestatus="loading".WaterfallChart.XAxis— Step names under the bands. Real text, so they follow the theme's font and the platform's text scaling.WaterfallChart.YAxis— Value labels down the side, and the step names when the chart is sideways. The chart reserves a gutter for them rather than drawing them over the plot.WaterfallChart.Tooltip— The drag that selects a step, and the card that reports it — the change, and the balance it left behind. The card follows whichever axis the bands run along.WaterfallChart.Legend— A swatch and a name for each of the three roles the run actually contains. A run with no totals in it does not list a colour for them.
Examples
The data
Steps in the order they happen. value is what that step changes the running total by, so a decrease is negative and the colour follows from the sign.
total: true marks a reading rather than a change. Its value is added to the running total before the bar is drawn, so an opening balance carries the figure it opens at and a closing total carries 0 — it reads the balance as it stands.
const bridge: WaterfallDatum[] = [
{ label: 'Q3', value: 482000, total: true },
{ label: 'New', value: 96400 },
{ label: 'Expansion', value: 41200 },
{ label: 'Churn', value: -58700 },
{ label: 'Downgrade', value: -19300 },
{ label: 'Q4', value: 0, total: true },
];Writing the changes on the bars
Values puts each step's change just past the end of its bar, signed. It reads the settled domain rather than the animation, so the numbers appear in place instead of counting up.
It needs vertical room. At the default aspectRatio of 2 a label sits close enough to the top of the plot to crowd the tallest bar — give the chart a squarer shape when you turn them on.
<WaterfallChart data={bridge} aspectRatio={1.5}>
<WaterfallChart.Grid />
<WaterfallChart.YAxis />
<WaterfallChart.Connectors />
<WaterfallChart.Bars />
<WaterfallChart.Values />
<WaterfallChart.XAxis />
<WaterfallChart.Tooltip />
</WaterfallChart>Sideways, when the names do not fit
orientation="horizontal" lays the run down the side and moves the step names into the gutter YAxis reserves on the left.
Reach for it once you have more than about five steps. Upright, seven columns across a phone is roughly forty points each, and a name that does not fit under one is a name nobody reads. Sideways each gets a full line. Values is dropped in this orientation — the readout carries the numbers instead.
<WaterfallChart data={cashFlow} orientation="horizontal" aspectRatio={0.95}>
<WaterfallChart.Grid />
<WaterfallChart.Connectors />
<WaterfallChart.Bars />
<WaterfallChart.YAxis />
<WaterfallChart.Tooltip />
</WaterfallChart>Reading a step above the chart
onActiveIndexChange fires with the index and the resolved step as the finger moves, so a header outside the plot can follow it. waterfallSteps is the same arithmetic the chart runs, exported so a header, a table or a total elsewhere on the screen can read the running balances without recomputing them differently.
const [active, setActive] = useState(-1);
const step = active >= 0 ? waterfallSteps(cashFlow)[active] : null;
<WaterfallChart data={cashFlow} aspectRatio={1.6} onActiveIndexChange={setActive}>
<WaterfallChart.Header
title={step ? step.label : 'Closing balance'}
value={money(step ? step.value : 85500)}
caption={step && step.kind !== 'total' ? `Balance after: ${money(step.end)}` : undefined}
/>
<WaterfallChart.Grid />
<WaterfallChart.Connectors />
<WaterfallChart.Bars />
<WaterfallChart.XAxis ticks={4} />
<WaterfallChart.Tooltip />
</WaterfallChart>Loading, and then data
status="loading" holds the bars at the baseline and grows them into the real ones when it turns ready. Add a Skeleton for something to stand in the plot meanwhile.
The stubs are equal and sit on the baseline. Floating them at differing heights would invent a set of running totals, and the reader has no way to tell an invented balance from a real one until it changes under them.
<WaterfallChart data={bridge} status={status}>
<WaterfallChart.Grid />
<WaterfallChart.Skeleton />
<WaterfallChart.Connectors />
<WaterfallChart.Bars />
<WaterfallChart.XAxis />
<WaterfallChart.Tooltip />
</WaterfallChart>Recolouring the three roles
riseColor, fallColor and totalColor set the whole chart's palette; a single color on one datum overrides its role for that bar alone — for pulling one line of a bridge out for comment.
Keep it to three. The chart's one advantage is that a bar's direction is legible before its label is, and a fourth colour is a meaning the reader has to be told.
<WaterfallChart
data={variance}
riseColor="#0ea5e9"
fallColor="#f97316"
totalColor="#475569"
>
<WaterfallChart.Header title="Budget variance" value="$12,400 over" legend />
<WaterfallChart.Connectors />
<WaterfallChart.Bars />
<WaterfallChart.XAxis />
</WaterfallChart>Versions
Basic
Two totals on the baseline, four changes floating between them.
<WaterfallChart data={bridge}>
<WaterfallChart.Header title="Closing" value="$541,600" legend />
<WaterfallChart.Grid />
<WaterfallChart.Connectors />
<WaterfallChart.Bars />
<WaterfallChart.XAxis />
<WaterfallChart.Tooltip />
</WaterfallChart>Values
Every change written at the end of its bar, signed, over a value axis.
<WaterfallChart data={bridge} aspectRatio={1.5}>
<WaterfallChart.Grid />
<WaterfallChart.YAxis />
<WaterfallChart.Connectors />
<WaterfallChart.Bars />
<WaterfallChart.Values />
<WaterfallChart.XAxis />
<WaterfallChart.Tooltip />
</WaterfallChart>Sideways
Seven steps down the side, because seven names do not fit across a phone.
<WaterfallChart data={cashFlow} orientation="horizontal" aspectRatio={0.95}>
<WaterfallChart.Grid />
<WaterfallChart.Connectors />
<WaterfallChart.Bars />
<WaterfallChart.YAxis />
<WaterfallChart.Tooltip />
</WaterfallChart>Reading a step
The header follows the finger — the step held, or the closing balance.
<WaterfallChart data={cashFlow} aspectRatio={1.6} onActiveIndexChange={setActive}>
<WaterfallChart.Header
title={step ? step.label : 'Closing balance'}
value={money(step ? step.value : 85500)}
/>
<WaterfallChart.Grid />
<WaterfallChart.Connectors />
<WaterfallChart.Bars />
<WaterfallChart.XAxis ticks={4} />
<WaterfallChart.Tooltip />
</WaterfallChart>Loading
Equal stubs on the baseline, because invented balances cannot be unseen.
<WaterfallChart data={bridge} status={status}>
<WaterfallChart.Grid />
<WaterfallChart.Skeleton />
<WaterfallChart.Connectors />
<WaterfallChart.Bars />
<WaterfallChart.XAxis />
<WaterfallChart.Tooltip />
</WaterfallChart>API Reference
WaterfallChart
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
data | WaterfallDatum[] | — | The steps, in the order they happen. |
status | WaterfallChartStatus | 'ready' | loading holds the bars at the baseline and grows them into the real ones when it turns ready. One component throughout, rather than a spinner swapped for a chart — swapping loses the transition. Add a WaterfallChart.Skeleton for something to stand in the plot meanwhile. |
aspectRatio | number | 2 | Width ÷ height. 2 is the wide card shape. |
animationDuration | number | 700 | Milliseconds for the bars to grow in on mount. |
domainDuration | number | 500 | Milliseconds for the value axis to settle after the data changes. |
yDomain | [number, number] | — | Fix the value axis instead of deriving it. The derived domain always includes zero, and one that does not is a run whose bars cannot be compared — pass this only when you mean it. |
orientation | WaterfallChartOrientation | 'vertical' | vertical stands the bars up; horizontal lays the run down the side. |
barGap | number | 0.34 | Fraction of each band left empty, 0 to 1. A fraction rather than a pixel gap so the proportions hold at any width. |
barWidth | number | — | Fixed bar thickness in points. Derived from the band when omitted. |
cornerRadius | number | 4 | Corner radius on the ends of a bar. |
minBarLength | number | 2 | Smallest length a non-zero bar is drawn at, in points. A step that rounds to nothing still happened, and a bar of zero length says it did not. |
fadedOpacity | number | 0.3 | Opacity of the bars that are not under the finger. |
riseColor | string | — | Colour of a step that adds. Defaults to the success token. |
fallColor | string | — | Colour of a step that subtracts. Defaults to the destructive token. |
totalColor | string | — | Colour of a total step. Defaults to the first chart token. |
onActiveIndexChange | (index: number, step: WaterfallStep | null) => void | — | The step under the finger as it moves, and -1/null when it lifts. Fires when the index changes, not per frame. |
compact | boolean | false | Drop the axis padding, for a run with no axis or readout. |
WaterfallChart.Grid
| Prop | Type | Default | Description |
|---|---|---|---|
rows | number | 4 | How many lines to draw across the value axis. |
color | string | — | |
dashArray | string | — | |
opacity | number | 1 |
WaterfallChart.Bars
| Prop | Type | Default | Description |
|---|---|---|---|
cornerRadius | number | 4 | Corner radius, overriding the chart's. |
WaterfallChart.Connectors
| Prop | Type | Default | Description |
|---|---|---|---|
color | string | — | |
dashArray | string | — | |
strokeWidth | number | 1 | |
opacity | number | 1 |
WaterfallChart.Skeleton
| Prop | Type | Default | Description |
|---|---|---|---|
bars | number | — | How many placeholder bars to draw. Defaults to one per step, and to six when the data has not arrived — the count is the one thing a loading chart can be honest about only if it already has the steps. |
duration | number | 1400 | Milliseconds for one pass of the sweep. |
color | string | — |
WaterfallChart.XAxis
| Prop | Type | Default | Description |
|---|---|---|---|
ticks | number | — | How many labels to show. Every step by default, thinned only when the bands get too narrow to read — pass a number to force it lower. |
format | (step: WaterfallStep, index: number) => string | — | Turn a step into its label. Defaults to its label. |
className | string | — |
WaterfallChart.YAxis
| Prop | Type | Default | Description |
|---|---|---|---|
ticks | number | — | How many labels to show along the value axis. |
format | (value: number) => string | — | Format a value for its label. Defaults to a compact number. |
className | string | — |
WaterfallChart.Values
| Prop | Type | Default | Description |
|---|---|---|---|
format | (step: WaterfallStep, index: number) => string | — | Format a step's number. Defaults to a signed compact number. |
className | string | — |
WaterfallChart.Tooltip
| Prop | Type | Default | Description |
|---|---|---|---|
formatValue | (step: WaterfallStep) => string | — | Format the step's change. Defaults to a signed compact number. |
formatTotal | (step: WaterfallStep) => string | null | — | Format the running total line. Return null to drop it. |
className | string | — |
WaterfallChart.Legend
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
labels | Partial<Record<WaterfallKind, string>> | — | Names for the three roles. |
WaterfallChart.Header
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
title | string | — | Small line above the value — what the run is of. |
value | string | — | The readout. The largest thing on the card, and the first thing read. |
caption | string | — | One muted line under the value — a period, a comparison, a total. |
labels | Partial<Record<WaterfallKind, string>> | — | Names for the three roles, as the legend takes. |
legend | boolean | false | Draw a swatch and a name per role along the trailing edge. Prefer this to WaterfallChart.Legend on a chart that has a header: the legend floats over the plot, where it competes with the bars for the same corner. |
Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.
Notes
What a total step means
total: true moves a bar onto the baseline and into the neutral colour. Its value is added to the running figure before the bar is drawn, which covers both uses with one rule: an opening balance carries the figure it opens at, and a closing total carries 0 so it reads the balance the run arrived at.
Why the axis always reaches zero
A bar is read by its length, and a step's position off the baseline is a running total the reader is being asked to measure. Cropping the axis breaks both readings at once. The derived domain therefore always includes zero. yDomain overrides it, and doing so is opting into a chart that misreads.
Why the connectors are worth drawing
Without them the bars are a row of rectangles at unexplained heights. The line from one bar's end to the next bar's start is what says the second continues the first, and it is the only part of the drawing that carries the sequence.
They are drawn across the full width of both bands rather than only the gap between them, so their ends disappear behind the bars they touch — put Connectors before Bars in the children for that to hold.
What it costs to draw
Six animated paths a frame for the bars — one per colour, each split into the bar under the finger and the rest — plus one for the connectors. A run of forty steps is seven animated props, not forty, and the split is what lets the rest dim without giving every bar its own opacity.
Each bar grows from its own start towards its end rather than up from the baseline. A step is a movement between two balances, and growing it from zero would animate a quantity the chart is not claiming.
Reduced motion
The grow-in and the domain tween are both skipped, and the chart draws straight to its final shape.
aspectRatio measures the plot, not the whole chart. The header sits above the drawing area rather than inside it, so a chart with one is taller than the ratio alone suggests.