Zum Inhalt springen
UI Kit
Accent

Wasserfalldiagramm

Wie aus einem Anfangswert ein Endwert wird, Schritt für Schritt. Als Brücke, als Rechnung mit Zwischensummen, in Zeilen für lange Namen oder als Geld rein und raus mit dem Kontostand. Auf dem Telefon werden die Säulen zu Zeilen.

Eine von 5 Components in Vergleichs-Charts

4 Variants

Variants

Brücke

Vom Vorjahr zum laufenden Jahr. Plus und Minus schweben auf dem Zwischenstand, feine Verbinder tragen ihn weiter.

Im Studio öffnen

Mit Zwischensummen

Ein Monat als Rechnung, mit Umsatz, Kosten in Gruppen und den Zwischensummen dazwischen wieder auf der Grundlinie.

Im Studio öffnen

Zeilen

Die Schritte als Zeilen, lange Namen links, die Veränderung am Ende jedes Balkens. Geht mit jeder Einheit, hier Millisekunden.

Im Studio öffnen

Zahlungsfluss

Einnahmen über und Ausgaben unter der Nulllinie je Monat, der Kontostand als Linie auf derselben Skala.

Im Studio öffnen

Details

Was Wasserfalldiagramm mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/waterfall-chart.tsx
Bringt mit
utilskiticonchartchart-figure
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 11 eigene Texte

Props

Die Schnittstelle WaterfallChartProps aus components/ui/waterfall-chart.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<ChartA11y, 'label'>, ChartFigureProps an.

  • 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.

Eingebaute Texte

Diese Texte schreibt Wasserfalldiagramm selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 11 Texte anzeigen
changeEnglisch ChangeDeutsch Veränderung
runningEnglisch Running totalDeutsch Zwischenstand
totalEnglisch TotalDeutsch Summe
stepEnglisch StepDeutsch Schritt
inEnglisch Money inDeutsch Einnahmen
outEnglisch Money outDeutsch Ausgaben
balanceEnglisch BalanceDeutsch Kontostand
monthEnglisch MonthDeutsch Monat
gainEnglisch GainDeutsch Plus
lossEnglisch LossDeutsch Minus
totalsEnglisch TotalsDeutsch Summen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Wasserfalldiagramm reagiert selbst auf die Tasten Escape, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
  • Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.

Bewegung

  • Ohne Animationen, sie steht sofort ruhig da.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 380 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Wasserfalldiagramm?

Mit aiw add @ai-website/waterfall-chart. Der Befehl legt die Datei components/ui/waterfall-chart.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, chart und chart-figure. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Wasserfalldiagramm?

4 Varianten, Brücke, Mit Zwischensummen, Zeilen und Zahlungsfluss. Du wählst sie mit der Prop variant, zum Beispiel variant="subtotals".

Kann ich die Texte von Wasserfalldiagramm übersetzen?

Ja. Wasserfalldiagramm bringt 11 eigene Texte auf Deutsch und Englisch mit, etwa „Veränderung“, „Zwischenstand“ und „Summe“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Wasserfalldiagramm einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Wasserfalldiagramm mit aiw_components und installieren sie mit aiw_add @ai-website/waterfall-chart. Nenn im Prompt die Variante, etwa variant="bridge", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Wasserfalldiagramm ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden WaterfallChart-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert