Waterfall chart
How a start value becomes an end value, step by step. As a bridge, a statement with subtotals, rows for long names, or money in and out with the balance. On phones the columns become rows.
One of 5 components in Comparison charts
Variants
Details
What Waterfall chart brings along, read from the registry and the source.
- Category
- Comparison charts
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/waterfall-chart.tsx
- Brings along
- utilskiticonchartchart-figure
- npm packages
- None
- Languages
- English and German, 11 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The WaterfallChartProps interface from components/ui/waterfall-chart.tsx, with the comments from the code. Also takes every prop of Omit<ChartA11y, 'label'>, ChartFigureProps.
labeloptionalstringAccessible name of the chart. Default: the title.
variantoptionalWaterfallVariantbridgesubtotalshorizontalcashflow
stepsoptionalWaterfallStep[]bridge, subtotals, horizontal: the steps in order
monthsoptionalCashflowMonth[]cashflow: money in and out per month
openingoptionalnumbercashflow: balance before the first month
formatoptionalIntl.NumberFormatOptionsValue format, e.g. { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }
heightoptionalnumberPlot height for the columns in px. Default 300.
Built-in texts
Waterfall chart writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 11 texts
| Key | English | German |
|---|---|---|
| change | English Change | German Veränderung |
| running | English Running total | German Zwischenstand |
| total | English Total | German Summe |
| step | English Step | German Schritt |
| in | English Money in | German Einnahmen |
| out | English Money out | German Ausgaben |
| balance | English Balance | German Kontostand |
| month | English Month | German Monat |
| gain | English Gain | German Plus |
| loss | English Loss | German Minus |
| totals | English Totals | German Summen |
Accessibility
Read from the source of the component.
- Waterfall chart handles the keys Escape, Arrow up, Arrow down, Arrow left, Arrow right, Home, End and Space itself.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 380 lines
'use client';
import { useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { ChartRoot, ChartSurface, ChartTable, ChartTooltip, Swatch, barPath, fill, niceTicks, numberFormatter, textWidth, tickFormatter, useEntrance, useIntlLocale, useShown, useWidth, xFormatters, type ChartA11y, type TooltipRow } from '@/components/ui/chart';
import { ChartFigure, PlotAxes, linear, type ChartFigureProps } from '@/components/ui/chart-figure';
/*
* How a start value becomes an end value, step by step.
* bridge start, the changes, the end: columns that float on the running total
* subtotals a statement with subtotals between the groups (profit and loss)
* horizontal the same as rows, long names on the left (also the phone layout of the two above)
* cashflow money in and out per month around the zero line, the balance as a line
*
* Totals stand in grey on the baseline, gains and losses float in the
* success and destructive tones, always with a sign and an arrow in the
* label, so red and green are never the only clue. Thin connectors carry
* the running total from one column to the next.
*/
export type WaterfallVariant = 'bridge' | 'subtotals' | 'horizontal' | 'cashflow';
export interface WaterfallStep {
label: string;
/** A change (positive or negative). For kind total: the value itself. For subtotal: ignored, the running total is used. */
value: number;
/** delta (default), total (stands on the baseline) or subtotal (the running total so far) */
kind?: 'delta' | 'total' | 'subtotal';
}
export interface CashflowMonth {
/** "2026-03" is read as a month and formatted per language */
month: string;
in: number;
out: number;
}
export interface WaterfallChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
/** Accessible name of the chart. Default: the title. */
label?: string;
variant?: WaterfallVariant;
/** bridge, subtotals, horizontal: the steps in order */
steps?: WaterfallStep[];
/** cashflow: money in and out per month */
months?: CashflowMonth[];
/** cashflow: balance before the first month */
opening?: number;
/** Value format, e.g. { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 } */
format?: Intl.NumberFormatOptions;
/** Plot height for the columns in px. Default 300. */
height?: number;
className?: string;
}
const TEXTS = {
en: { change: 'Change', running: 'Running total', total: 'Total', step: 'Step', in: 'Money in', out: 'Money out', balance: 'Balance', month: 'Month', gain: 'Gain', loss: 'Loss', totals: 'Totals' },
de: { change: 'Veränderung', running: 'Zwischenstand', total: 'Summe', step: 'Schritt', in: 'Einnahmen', out: 'Ausgaben', balance: 'Kontostand', month: 'Monat', gain: 'Plus', loss: 'Minus', totals: 'Summen' },
};
const UP = 'var(--kitfg-up)';
const DOWN = 'var(--kitfg-down)';
const TOTAL = 'var(--muted-foreground)';
interface Bar {
label: string;
kind: 'delta' | 'total' | 'subtotal';
change: number;
from: number;
to: number;
}
function bars(steps: WaterfallStep[]): Bar[] {
let run = 0;
return steps.map((s, i) => {
const kind = s.kind ?? (i === 0 ? 'total' : 'delta');
if (kind === 'total') {
run = s.value;
return { label: s.label, kind, change: s.value, from: 0, to: s.value };
}
if (kind === 'subtotal') return { label: s.label, kind, change: run, from: 0, to: run };
const from = run;
run += s.value;
return { label: s.label, kind, change: s.value, from, to: run };
});
}
/**
* Start to end in steps: a bridge, a statement with subtotals, rows, or cash in and out with the balance.
* <WaterfallChart variant="bridge" steps={[{ label: '2025', value: 412000, kind: 'total' }, { label: 'New customers', value: 86000 }, …]} />
*/
export function WaterfallChart(props: WaterfallChartProps) {
const { variant = 'bridge', steps = [], months = [], opening = 0, format, height: heightProp, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
const t = useTexts(TEXTS, undefined);
const intl = useIntlLocale();
const fmt = numberFormatter(intl, format);
const tick = tickFormatter(intl, format);
const signed = (n: number) => (n > 0 ? '+' : n < 0 ? '−' : '') + fmt(Math.abs(n));
const [wrapRef, width] = useWidth<HTMLDivElement>(720);
const short = (n: number) => {
const c = tick(n);
return /\p{L}/u.test(c.replace(/[€$£]|ms|GB|kW/g, '')) ? c : fmt(n);
};
const signedShort = (n: number) => (n > 0 ? '+' : n < 0 ? '\u2212' : '') + short(Math.abs(n));
const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
const [active, setActive] = useState<number | null>(null);
const { shown, open, jump } = useShown(active);
const name = label ?? title ?? '';
const phone = width < 520;
const cash = variant === 'cashflow';
// Columns only while every name fits its column (two lines allowed), else rows: tablets get rows too when names are long
const colBand = (width - 60) / Math.max(1, steps.length);
const fitsColumn = (s: string) => {
const w = s.split(' ');
const h = Math.ceil(w.length / 2);
const lw = w.length < 2 ? textWidth(s, 11) : Math.max(textWidth(w.slice(0, h).join(' '), 11), textWidth(w.slice(h).join(' '), 11));
return lw <= colBand - 6;
};
const rows = variant === 'horizontal' || (!cash && (phone || !steps.every((st) => fitsColumn(st.label))));
const list = bars(steps);
// Start above zero when every value sits far from it, else the changes shrink to slivers. Totals then carry a break mark.
const runs = list.flatMap((b) => (b.kind === 'delta' ? [b.from, b.to] : [b.to]));
const minRun = Math.min(...runs);
const maxRun = Math.max(...runs);
const cut = !cash && minRun > 0 && minRun > maxRun * 0.45;
const floor = cut ? niceTicks(minRun - (maxRun - minRun) * 0.55, maxRun, 5).min : 0;
// Cashflow model
const balance: number[] = [];
months.reduce((b, m) => {
const n = b + m.in - m.out;
balance.push(n);
return n;
}, opening);
const xf = xFormatters(intl, months[0]?.month);
const n = cash ? months.length : list.length;
const onKeyDown = (e: React.KeyboardEvent) => {
const cur = active ?? -1;
let next: number | null = cur;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = Math.min(n - 1, cur + 1);
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = Math.max(0, cur - 1);
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = n - 1;
else if (e.key === 'Escape') next = null;
else return;
e.preventDefault();
setActive(next);
};
const tipRows = (i: number): TooltipRow[] => {
if (cash) {
const m = months[i];
return [
{ key: 'in', label: t.in, value: fmt(m.in), color: UP, shape: 'rect' },
{ key: 'out', label: t.out, value: fmt(m.out), color: DOWN, shape: 'rect' },
{ key: 'bal', label: t.balance, value: fmt(balance[i]), color: 'var(--primary)', shape: 'line' },
];
}
const b = list[i];
return b.kind === 'delta'
? [
{ key: 'c', label: t.change, value: signed(b.change), color: b.change >= 0 ? UP : DOWN, shape: 'rect' },
{ key: 'r', label: t.running, value: fmt(b.to) },
]
: [{ key: 't', label: b.kind === 'subtotal' ? t.running : t.total, value: fmt(b.to), color: TOTAL, shape: 'rect' }];
};
const titleOf = (i: number) => (cash ? xf.tooltip(months[i]?.month) : list[i]?.label ?? '');
const live = active !== null && active < n ? `${titleOf(active)}. ${tipRows(active).map((r) => `${r.label} ${r.value}`).join(', ')}` : '';
const auto = cash
? `${t.balance} ${fmt(opening)} → ${fmt(balance[balance.length - 1] ?? opening)}`
: list.length
? `${list[0].label} ${fmt(list[0].to)}, ${list[list.length - 1].label} ${fmt(list[list.length - 1].to)}`
: '';
const table = cash ? (
<ChartTable caption={name} head={[t.month, t.in, t.out, t.balance]} rows={months.map((m, i) => [xf.tooltip(m.month), fmt(m.in), fmt(m.out), fmt(balance[i])])} />
) : (
<ChartTable caption={name} head={[t.step, t.change, t.running]} rows={list.map((b) => [b.label, b.kind === 'delta' ? signed(b.change) : fmt(b.to), fmt(b.to)])} />
);
const legend = (
<ul className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12px] text-muted-foreground">
{cash ? (
<>
<li className="flex items-center gap-1.5"><Swatch color={UP} />{t.in}</li>
<li className="flex items-center gap-1.5"><Swatch color={DOWN} />{t.out}</li>
<li className="flex items-center gap-1.5"><Swatch color="var(--primary)" shape="line" />{t.balance}</li>
</>
) : (
<>
<li className="flex items-center gap-1.5"><Swatch color={TOTAL} />{t.totals}</li>
<li className="flex items-center gap-1.5"><Swatch color={UP} /><Icon name="arrow-up-right" size={13} strokeWidth={2} />{t.gain}</li>
<li className="flex items-center gap-1.5"><Swatch color={DOWN} /><Icon name="arrow-down-right" size={13} strokeWidth={2} />{t.loss}</li>
</>
)}
</ul>
);
const delayOf = (i: number) => (anim ? ({ '--kitch-d': `${round(100 + i * 110, 0)}ms` } as React.CSSProperties) : undefined);
/* ---------- rows ---------- */
if (rows) {
const rowH = 38;
const height = n * rowH + 28;
const all = list.flatMap((b) => [b.from, b.to]);
const nice = niceTicks(Math.min(floor, ...all.filter((v) => v >= floor)), Math.max(0, ...all), phone ? 3 : 5);
const labelW = Math.min(Math.round(width * (phone ? 0.36 : 0.28)), Math.max(...list.map((b) => textWidth(b.label, 12))) + 12);
const valueW = Math.max(...list.map((b) => textWidth(b.kind === 'delta' ? signedShort(b.change) : short(b.to), 12))) + 14;
const left = labelW + 8;
const right = width - 4;
const X = linear(nice.min, nice.max, left, right - valueW);
const top = 4;
const thick = 18;
const pick = (clientY: number, el: HTMLElement) => {
const r = el.getBoundingClientRect();
setActive(Math.max(0, Math.min(n - 1, Math.floor((clientY - r.top - top) / rowH))));
};
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
{legend}
<div ref={wrapRef}>
<ChartSurface label={name} summary={summary ?? auto} labels={labels} live={live} table={table} onKeyDown={onKeyDown} onFocus={(e) => e.target === e.currentTarget && active === null && e.currentTarget.matches(':focus-visible') && setActive(0)} onBlur={() => setActive(null)} onPointerMove={(e) => pick(e.clientY, e.currentTarget)} onPointerDown={(e) => pick(e.clientY, e.currentTarget)} onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}>
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
{nice.ticks.map((v, i) => {
const gap = nice.ticks.length > 1 ? X(nice.ticks[1]) - X(nice.ticks[0]) : width;
const every = Math.max(1, Math.ceil((Math.max(...nice.ticks.map((u) => textWidth(tick(u), 11))) + 10) / gap));
return (
<g key={v}>
<line x1={X(v)} x2={X(v)} y1={top} y2={top + n * rowH} stroke={v === 0 || (cut && i === 0) ? 'var(--kitch-axis)' : 'var(--kitch-grid)'} strokeWidth="1" shapeRendering="crispEdges" />
{i % every === 0 && (
<text x={X(v)} y={height - 6} textAnchor="middle" className="text-[11px] tabular-nums" fill="var(--muted-foreground)">
{tick(v)}
</text>
)}
</g>
);
})}
{list.map((b, i) => {
const cy = top + i * rowH + rowH / 2;
const a = X(Math.max(nice.min, Math.min(b.from, b.to)));
const z = X(Math.max(b.from, b.to));
const color = b.kind === 'delta' ? (b.change >= 0 ? UP : DOWN) : TOTAL;
const isTotal = b.kind !== 'delta';
const next = list[i + 1];
return (
<g key={i} className="transition-opacity duration-200" opacity={active !== null && active !== i ? 0.5 : 1}>
{active === i && <rect x={0} y={cy - rowH / 2 + 2} width={width} height={rowH - 4} rx="6" fill="var(--foreground)" opacity="0.05" />}
<text x={0} y={cy} dy="0.34em" className={cn('text-[12px]', isTotal ? 'font-semibold' : '')} fill={isTotal || active === i ? 'var(--foreground)' : 'var(--muted-foreground)'}>
{b.label}
</text>
{next && <line x1={X(b.to)} x2={X(b.to)} y1={cy + thick / 2} y2={cy + rowH - thick / 2} stroke="var(--kitch-axis)" strokeWidth="1" strokeDasharray="2 2" className={anim ? 'kitch-fade' : undefined} style={delayOf(i + 1)} />}
{isTotal && cut && <path d={`M${a + 9} ${cy - thick / 2 - 2}l-4 ${thick + 4}M${a + 14} ${cy - thick / 2 - 2}l-4 ${thick + 4}`} stroke="var(--kitch-surface)" strokeWidth="2.5" className="pointer-events-none" style={{ zIndex: 1 }} />}
<path d={barPath(a, cy - thick / 2, Math.max(1, z - a), thick, 3, b.change >= 0 || isTotal ? 'right' : 'left')} fill={color} className={anim ? 'kitch-grow-x' : undefined} style={{ ...delayOf(i), transformOrigin: b.change >= 0 || isTotal ? `${a}px ${cy}px` : `${z}px ${cy}px`, transformBox: 'view-box' }} />
<text x={z + 8} y={cy} dy="0.34em" className={cn('text-[12px] tabular-nums', anim && 'kitch-rise')} style={delayOf(i + 2)} fill="var(--foreground)" fontWeight={isTotal ? 600 : 500}>
{b.kind === 'delta' ? signedShort(b.change) : short(b.to)}
</text>
</g>
);
})}
</svg>
</ChartSurface>
</div>
</ChartRoot>
</ChartFigure>
);
}
/* ---------- columns ---------- */
const height = heightProp ?? 320;
const all = cash ? [0, ...months.flatMap((m) => [m.in, -m.out]), ...balance, opening] : [floor, ...runs];
const nice = niceTicks(Math.min(...all), Math.max(...all), 5);
const yLabels = nice.ticks.map(tick);
const left = Math.max(...yLabels.map((s) => textWidth(s, 11)), 14) + 14;
const right = width - 6;
const top = 26;
const bottom = height - (cash ? 28 : 46);
const Y = linear(nice.min, nice.max, bottom, top);
const band = (right - left) / Math.max(1, n);
const cx = (i: number) => round(left + band * (i + 0.5));
const thick = Math.min(cash ? 14 : 36, band * (cash ? 0.3 : 0.56));
const pick = (clientX: number, el: HTMLElement) => {
const r = el.getBoundingClientRect();
const x = (clientX - r.left) * (width / (r.width || width));
setActive(Math.max(0, Math.min(n - 1, Math.floor((x - left) / band))));
};
const catLabels = cash ? months.map((m) => xf.axis(m.month)) : list.map((b) => b.label);
// Labels wrap to two lines before any is skipped
const lineW = (s: string) => {
const w = s.split(' ');
if (cash || w.length < 2) return textWidth(s, 11);
const h = Math.ceil(w.length / 2);
return Math.max(textWidth(w.slice(0, h).join(' '), 11), textWidth(w.slice(h).join(' '), 11));
};
const every = Math.max(1, Math.ceil((Math.max(...catLabels.map(lineW)) + 8) / band));
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
{legend}
<div ref={wrapRef}>
<ChartSurface label={name} summary={summary ?? auto} labels={labels} live={live} table={table} onKeyDown={onKeyDown} onFocus={(e) => e.target === e.currentTarget && active === null && e.currentTarget.matches(':focus-visible') && setActive(0)} onBlur={() => setActive(null)} onPointerMove={(e) => pick(e.clientX, e.currentTarget)} onPointerDown={(e) => pick(e.clientX, e.currentTarget)} onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}>
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
<PlotAxes left={left} right={right} top={top} bottom={bottom} height={height} yTicks={nice.ticks.map((v, i) => ({ at: Y(v), label: yLabels[i], strong: v === 0 || (cut && i === 0) }))} />
{shown !== null && <rect x={0} y={top - 8} width={round(band - 4)} height={bottom - top + 8} rx="6" fill="var(--foreground)" className="kitch-glide" data-jump={jump ? '' : undefined} style={{ transform: `translateX(${round(left + band * shown + 2)}px)`, opacity: open ? 0.045 : 0 }} />}
{/* Category labels, wrapped on two lines where needed */}
<g className="text-[11px]" fill="var(--muted-foreground)">
{catLabels.map((s, i) => {
if (i % every) return null;
const words = s.split(' ');
const two = !cash && textWidth(s, 11) > band - 6 && words.length > 1;
const half = Math.ceil(words.length / 2);
return (
<text key={i} x={cx(i)} y={bottom + 18} textAnchor="middle" className={cn(active === i && 'fill-foreground', !cash && list[i]?.kind !== 'delta' && 'font-semibold')}>
{two ? (
<>
<tspan x={cx(i)}>{words.slice(0, half).join(' ')}</tspan>
<tspan x={cx(i)} dy="13">
{words.slice(half).join(' ')}
</tspan>
</>
) : (
s
)}
</text>
);
})}
</g>
{cash ? (
<>
{months.map((m, i) => (
<g key={m.month} className="transition-opacity duration-200" opacity={active !== null && active !== i ? 0.55 : 1}>
<path d={barPath(cx(i) - thick - 1, Y(m.in), thick, Y(0) - Y(m.in), 3, 'up')} fill={UP} className={anim ? 'kitch-grow-y' : undefined} style={delayOf(i * 0.5)} />
<path d={barPath(cx(i) + 1, Y(0), thick, Y(-m.out) - Y(0), 3, 'down')} fill={DOWN} className={anim ? 'kitch-grow-y-down' : undefined} style={delayOf(i * 0.5)} />
</g>
))}
<path d={balance.map((b, i) => `${i ? 'L' : 'M'}${cx(i)} ${Y(b)}`).join('')} pathLength={1} fill="none" stroke="var(--primary)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" className={anim ? 'kitch-draw' : undefined} style={anim ? ({ '--kitch-d': '700ms', '--kitch-t': '1.2s' } as React.CSSProperties) : undefined} />
{balance.map((b, i) => (
<circle key={i} cx={cx(i)} cy={Y(b)} r={active === i || i === balance.length - 1 ? 4.5 : 0} fill="var(--primary)" stroke="var(--kitch-surface)" strokeWidth="2" className="transition-[r] duration-200" />
))}
{balance.length > 0 && (
<text x={cx(balance.length - 1)} y={Y(balance[balance.length - 1]) - 12} textAnchor="end" className={cn('text-[12px] font-semibold tabular-nums', anim && 'kitch-rise')} fill="var(--foreground)" style={anim ? ({ '--kitch-d': '1700ms', paintOrder: 'stroke', stroke: 'var(--kitch-surface)', strokeWidth: 4 } as React.CSSProperties) : { paintOrder: 'stroke', stroke: 'var(--kitch-surface)', strokeWidth: 4 }}>
{tick(balance[balance.length - 1])}
</text>
)}
</>
) : (
list.map((b, i) => {
const isTotal = b.kind !== 'delta';
const color = isTotal ? TOTAL : b.change >= 0 ? UP : DOWN;
const yTop = Y(Math.max(b.from, b.to));
const yBot = Y(Math.max(nice.min, Math.min(b.from, b.to)));
const h = Math.max(1, yBot - yTop);
const up = isTotal ? b.to >= 0 : b.change >= 0;
const next = list[i + 1];
const text = isTotal ? short(b.to) : signedShort(b.change);
const fits = textWidth(text, 11) <= band - 4;
return (
<g key={i} className="transition-opacity duration-200" opacity={active !== null && active !== i ? 0.55 : 1}>
{next && <line x1={cx(i) + thick / 2} x2={cx(i + 1) - thick / 2} y1={Y(b.to)} y2={Y(b.to)} stroke="var(--kitch-axis)" strokeWidth="1" strokeDasharray="2 2" className={anim ? 'kitch-fade' : undefined} style={delayOf(i + 1)} />}
<path d={barPath(cx(i) - thick / 2, yTop, thick, h, 4, up ? 'up' : 'down')} fill={color} className={anim ? (up ? 'kitch-grow-y' : 'kitch-grow-y-down') : undefined} style={{ ...delayOf(i), ...(isTotal ? {} : { transformOrigin: up ? `50% 100%` : `50% 0` }) }} />
{isTotal && cut && <path d={`M${cx(i) - thick / 2 - 2} ${yBot - 8}l${thick + 4} -5M${cx(i) - thick / 2 - 2} ${yBot - 13}l${thick + 4} -5`} stroke="var(--kitch-surface)" strokeWidth="2.5" className={anim ? 'kitch-fade' : undefined} style={delayOf(i + 2)} />}
{fits && (
<text x={cx(i)} y={up ? yTop - 7 : yBot + 15} textAnchor="middle" className={cn('text-[11px] tabular-nums', anim && 'kitch-rise')} style={delayOf(i + 2)} fill="var(--foreground)" fontWeight={isTotal ? 600 : 500}>
{text}
</text>
)}
</g>
);
})
)}
</svg>
<ChartTooltip open={open} jump={jump} x={shown !== null ? cx(shown) : 0} y={top} width={width} offset={Math.min(band / 2, 40) + 6} title={shown !== null ? titleOf(shown) : ''} rows={shown !== null ? tipRows(shown) : []} />
</ChartSurface>
</div>
</ChartRoot>
</ChartFigure>
);
}Frequent questions
How do I install Waterfall chart?
With aiw add @ai-website/waterfall-chart. The command puts components/ui/waterfall-chart.tsx into your project, the code is yours from then on. It brings along icon, chart and chart-figure. It needs no further npm packages.
Which variants does Waterfall chart have?
4 variants, Bridge, With subtotals, Rows and Cash flow. You pick one with the variant prop, for example variant="subtotals".
Can I translate the texts of Waterfall chart?
Yes. Waterfall chart carries 11 texts of its own in English and German, such as “Change”, “Running total” and “Total”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Waterfall chart?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Waterfall chart with aiw_components and install it with aiw_add @ai-website/waterfall-chart. Name the variant in your prompt, for example variant="bridge", and the assistant writes your content straight into the props.
Can I adjust Waterfall chart without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching WaterfallChart code to copy.