Skip to content
UI Kit
Accent color

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

4 variants

Variants

Bridge

From last year to this year. Gains and losses float on the running total, thin connectors carry it on.

Open in Studio

With subtotals

A month as a statement, with revenue, costs in groups and the subtotals between them back on the baseline.

Open in Studio

Rows

The steps as rows, long names on the left, the change at each bar end. Works for any unit, here milliseconds.

Open in Studio

Cash flow

Money in above and out below the zero line per month, the balance as a line on the same scale.

Open in Studio

Details

What Waterfall chart brings along, read from the registry and the source.

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

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.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • variantoptional
    WaterfallVariant

    bridgesubtotalshorizontalcashflow

  • stepsoptional
    WaterfallStep[]

    bridge, subtotals, horizontal: the steps in order

  • monthsoptional
    CashflowMonth[]

    cashflow: money in and out per month

  • openingoptional
    number

    cashflow: balance before the first month

  • formatoptional
    Intl.NumberFormatOptions

    Value format, e.g. { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }

  • heightoptional
    number

    Plot 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
changeEnglish ChangeGerman Veränderung
runningEnglish Running totalGerman Zwischenstand
totalEnglish TotalGerman Summe
stepEnglish StepGerman Schritt
inEnglish Money inGerman Einnahmen
outEnglish Money outGerman Ausgaben
balanceEnglish BalanceGerman Kontostand
monthEnglish MonthGerman Monat
gainEnglish GainGerman Plus
lossEnglish LossGerman Minus
totalsEnglish TotalsGerman 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
components/ui/waterfall-chart.tsx
'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.

Often searched

Jump to

Copied to the clipboard