Waterfall Chart
Charts

Waterfall Chart

A hand-built SVG bridge chart with floating signed deltas, subtotal pillars, dashed carry connectors, semantic positive/negative tints, collision-avoided value labels, and a staggered grow-in from each bar's own carry level.

Install

npx shadcn@latest add @paragon/waterfall-chart

waterfall-chart.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface WaterfallItem {
  label: string;
  /** Signed change for deltas; absolute value for `start`. */
  value: number;
  /**
   * `start` anchors the opening pillar, `delta` floats a signed change,
   * `subtotal`/`end` drop a full pillar at the running total.
   */
  type?: "delta" | "start" | "subtotal" | "end";
}

export interface WaterfallChartProps extends React.ComponentProps<"div"> {
  items: WaterfallItem[];
  height?: number;
  /** Fill for the start/subtotal/end pillars. */
  pillarColor?: string;
  /** Dashed carry-line connectors between bars. */
  showConnectors?: boolean;
  /** Signed value labels above/below each bar. */
  showValues?: boolean;
  showGrid?: boolean;
  showYAxis?: boolean;
  formatValue?: (value: number) => string;
  /** Accessible description of the chart. */
  label?: string;
  /** Renders the final state immediately, no staggered rise. */
  static?: boolean;
}

/** Nice 1/2/5 ticks spanning a possibly-negative domain, always crossing 0. */
function niceTicksRange(min: number, max: number, count = 4): number[] {
  const lo = Math.min(min, 0);
  const hi = Math.max(max, 0);
  const span = hi - lo || 1;
  const rough = span / count;
  const magnitude = Math.pow(10, Math.floor(Math.log10(rough)));
  const norm = rough / magnitude;
  const step = (norm > 5 ? 10 : norm > 2 ? 5 : norm > 1 ? 2 : 1) * magnitude;
  const bottom = Math.floor(lo / step) * step;
  const top = Math.ceil(hi / step) * step;
  const ticks: number[] = [];
  for (let v = bottom; v <= top + step / 2; v += step) ticks.push(v);
  return ticks;
}

/**
 * A hand-built SVG waterfall (bridge) chart: an opening pillar, floating
 * signed deltas, and subtotal/closing pillars, every bar edge computed from
 * one linear scale so carries line up exactly. Positive and negative changes
 * take the semantic success/destructive tints; dashed connector ticks carry
 * each running total to the next bar. Bars grow from their own carry level
 * with a stagger once on first view, then connectors and collision-avoided
 * value labels fade in. Hovering or focusing a column (arrows, Home, End)
 * shows the change and running total in an edge-flipping tooltip. Reduced
 * motion renders the final state.
 */
export function WaterfallChart({
  items,
  height = 260,
  pillarColor = "var(--color-primary)",
  showConnectors = true,
  showValues = true,
  showGrid = true,
  showYAxis = true,
  formatValue = (v) => v.toLocaleString("en-US"),
  label,
  static: isStatic = false,
  className,
  ...props
}: WaterfallChartProps) {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const [width, setWidth] = React.useState(0);
  const reducedMotion = useReducedMotion();
  const inView = useInView(containerRef, {
    once: true,
    margin: "0px 0px -48px 0px",
  });
  const [tip, setTip] = React.useState({ index: 0, x: 0, y: 0, visible: false });

  React.useLayoutEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const observer = new ResizeObserver(([entry]) =>
      setWidth(entry.contentRect.width),
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  const animate = !isStatic && !reducedMotion;
  const drawn = !animate || inView;

  const n = items.length;
  const hasData = n > 0;

  // Resolve each item into a [from → to] span on the running total.
  const bars = React.useMemo(() => {
    let running = 0;
    return items.map((it) => {
      const kind = it.type ?? "delta";
      if (kind === "start") {
        running = it.value;
        return { ...it, kind, from: 0, to: it.value, delta: it.value, running };
      }
      if (kind === "subtotal" || kind === "end") {
        return { ...it, kind, from: 0, to: running, delta: running, running };
      }
      const from = running;
      running += it.value;
      return { ...it, kind, from, to: running, delta: it.value, running };
    });
  }, [items]);

  const pad = { top: showValues ? 20 : 10, right: 12, bottom: 22, left: showYAxis ? 44 : 8 };
  const innerW = Math.max(width - pad.left - pad.right, 0);
  const plotBottom = height - pad.bottom;
  const innerH = plotBottom - pad.top;

  const lo = Math.min(0, ...bars.flatMap((b) => [b.from, b.to]));
  const hi = Math.max(1, ...bars.flatMap((b) => [b.from, b.to]));
  const ticks = niceTicksRange(lo, hi);
  const yMin = ticks[0];
  const yMax = ticks[ticks.length - 1];
  const yFor = (v: number) =>
    plotBottom - ((v - yMin) / (yMax - yMin || 1)) * innerH;

  const band = n > 0 ? innerW / n : 0;
  const barW = band * 0.58;
  const xBand = (i: number) => pad.left + i * band + (band - barW) / 2;

  const colorFor = (b: (typeof bars)[number]) =>
    b.kind !== "delta"
      ? pillarColor
      : b.delta >= 0
        ? "var(--color-success)"
        : "var(--color-destructive)";

  // Category labels skip evenly when bands get narrower than the text needs
  // (~6px per character at 10px type), so they never collide.
  const maxLabelChars = items.reduce((m, it) => Math.max(m, it.label.length), 0);
  const labelStep =
    band > 0 ? Math.max(1, Math.ceil((maxLabelChars * 6 + 8) / band)) : 1;

  const signedFormat = (v: number) =>
    `${v > 0 ? "+" : v < 0 ? "−" : ""}${formatValue(Math.abs(v))}`;

  const setIndex = React.useCallback(
    (index: number) => {
      const i = Math.min(n - 1, Math.max(0, index));
      setTip({ index: i, x: pad.left + i * band + band / 2, y: pad.top, visible: true });
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [n, band, pad.left],
  );

  const handleMove = (event: React.PointerEvent<SVGSVGElement>) => {
    const rect = containerRef.current?.getBoundingClientRect();
    if (!rect || band <= 0) return;
    const px = event.clientX - rect.left;
    const py = event.clientY - rect.top;
    const index = Math.min(n - 1, Math.max(0, Math.floor((px - pad.left) / band)));
    setTip({ index, x: px, y: py, visible: true });
  };

  const flip = width > 0 && tip.x > width * 0.6;
  const flipY = tip.y > height * 0.6;
  const tipBar = bars[tip.index];

  return (
    <div
      ref={containerRef}
      data-slot="waterfall-chart"
      className={cn("relative w-full", className)}
      {...props}
    >
      {!hasData ? (
        <div
          style={{ height }}
          className="flex flex-col items-center justify-center gap-1.5 rounded-lg border border-dashed text-sm text-muted-foreground"
        >
          <svg width="28" height="16" viewBox="0 0 28 16" aria-hidden className="opacity-50">
            <path d="M1 15V6h5v4h5V4h5v6h5V1h6" fill="none" stroke="currentColor" strokeLinejoin="round" />
          </svg>
          No bridge data
        </div>
      ) : width > 0 ? (
        <svg
          role="img"
          aria-label={label ?? `Waterfall chart of ${items.map((i) => i.label).join(", ")}`}
          width={width}
          height={height}
          viewBox={`0 0 ${width} ${height}`}
          preserveAspectRatio="xMidYMid meet"
          className="block touch-none overflow-visible"
          onPointerMove={handleMove}
          onPointerDown={handleMove}
          onPointerLeave={(e) => {
            if (e.pointerType === "touch") return;
            setTip((t) => ({ ...t, visible: false }));
          }}
        >
          {showGrid &&
            ticks.map((tick) => (
              <line
                key={tick}
                x1={pad.left}
                x2={width - pad.right}
                y1={yFor(tick)}
                y2={yFor(tick)}
                stroke="currentColor"
                strokeOpacity={tick === 0 ? 0.2 : 0.07}
                vectorEffect="non-scaling-stroke"
                shapeRendering="crispEdges"
              />
            ))}
          {showYAxis &&
            ticks.map((tick) => (
              <text
                key={tick}
                x={pad.left - 8}
                y={yFor(tick)}
                textAnchor="end"
                dominantBaseline="central"
                fontSize={10}
                className="fill-muted-foreground tabular-nums"
              >
                {formatValue(tick)}
              </text>
            ))}
          {items.map((it, i) =>
            i % labelStep === 0 ? (
              <text
                key={i}
                x={pad.left + i * band + band / 2}
                y={height - 6}
                textAnchor="middle"
                fontSize={10}
                className="fill-muted-foreground"
              >
                {it.label}
              </text>
            ) : null,
          )}

          {/* Hovered column band — instant */}
          {tip.visible && (
            <rect
              x={pad.left + tip.index * band}
              y={pad.top}
              width={band}
              height={innerH}
              fill="currentColor"
              fillOpacity={0.05}
              aria-hidden
            />
          )}

          {/* Bars grow from their own carry level, staggered left → right */}
          {bars.map((b, i) => {
            const yTop = yFor(Math.max(b.from, b.to));
            const h = Math.max(Math.abs(yFor(b.from) - yFor(b.to)), 1.5);
            const originY = b.kind === "delta" ? yFor(b.from) : yFor(0);
            return (
              <g
                key={i}
                style={{
                  transform: drawn ? "scaleY(1)" : "scaleY(0)",
                  transformOrigin: `0px ${originY}px`,
                  transition: animate
                    ? `transform calc(340ms * var(--duration-scale, 1)) var(--ease-out) calc(${
                        i * 70
                      }ms * var(--duration-scale, 1))`
                    : undefined,
                }}
              >
                <rect
                  x={xBand(i)}
                  y={yTop}
                  width={Math.max(barW, 1)}
                  height={h}
                  rx={Math.min(2.5, barW / 2)}
                  fill={colorFor(b)}
                  fillOpacity={
                    tip.visible ? (tip.index === i ? 1 : 0.45) : b.kind === "delta" ? 0.85 : 0.92
                  }
                  style={{ transition: "fill-opacity 150ms var(--ease-out)" }}
                />
              </g>
            );
          })}

          {/* Connector ticks carry each running level to the next bar */}
          {showConnectors &&
            bars.slice(0, -1).map((b, i) => (
              <line
                key={i}
                x1={xBand(i) + barW}
                x2={xBand(i + 1)}
                y1={yFor(b.to)}
                y2={yFor(b.to)}
                stroke="currentColor"
                strokeOpacity={0.35}
                strokeDasharray="3 3"
                vectorEffect="non-scaling-stroke"
                shapeRendering="crispEdges"
                aria-hidden
                style={{
                  opacity: drawn ? 1 : 0,
                  transition: animate
                    ? `opacity calc(200ms * var(--duration-scale, 1)) var(--ease-out) calc(${
                        i * 70 + 280
                      }ms * var(--duration-scale, 1))`
                    : undefined,
                }}
              />
            ))}

          {/* Signed value labels — flipped/clamped so they never leave the plot,
              and skipped entirely when the band can't fit the text */}
          {showValues &&
            bars.map((b, i) => {
              const text = b.kind === "delta" ? signedFormat(b.delta) : formatValue(b.to);
              if (text.length * 5.8 + 4 > band) return null;
              const above = b.kind !== "delta" || b.delta >= 0;
              const edgeY = yFor(Math.max(b.from, b.to));
              const bottomY = yFor(Math.min(b.from, b.to));
              const ideal = above ? edgeY - 5 : bottomY + 12;
              const y = Math.min(Math.max(ideal, pad.top + 8), plotBottom - 3);
              return (
                <text
                  key={i}
                  x={pad.left + i * band + band / 2}
                  y={y}
                  textAnchor="middle"
                  fontSize={9.5}
                  fontWeight={b.kind === "delta" ? 400 : 600}
                  className={cn(
                    "tabular-nums",
                    b.kind !== "delta"
                      ? "fill-foreground"
                      : b.delta >= 0
                        ? "fill-success"
                        : "fill-destructive",
                  )}
                  style={{
                    opacity: drawn ? 1 : 0,
                    transition: animate
                      ? `opacity calc(200ms * var(--duration-scale, 1)) var(--ease-out) calc(${
                          i * 70 + 320
                        }ms * var(--duration-scale, 1))`
                      : undefined,
                  }}
                >
                  {text}
                </text>
              );
            })}

          {/* Keyboard-focusable column hit targets */}
          {band > 0 &&
            bars.map((b, i) => (
              <rect
                key={i}
                x={pad.left + i * band}
                y={pad.top}
                width={band}
                height={innerH}
                fill="transparent"
                tabIndex={0}
                role="button"
                aria-label={
                  b.kind === "delta"
                    ? `${b.label}: change ${signedFormat(b.delta)}, running total ${formatValue(b.to)}`
                    : `${b.label}: total ${formatValue(b.to)}`
                }
                className="cursor-pointer outline-none [&:focus-visible]:stroke-ring"
                style={{ strokeWidth: 2 }}
                onFocus={() => setIndex(i)}
                onBlur={() => setTip((t) => ({ ...t, visible: false }))}
                onKeyDown={(e) => {
                  const target =
                    e.key === "ArrowRight" || e.key === "ArrowUp"
                      ? Math.min(n - 1, i + 1)
                      : e.key === "ArrowLeft" || e.key === "ArrowDown"
                        ? Math.max(0, i - 1)
                        : e.key === "Home"
                          ? 0
                          : e.key === "End"
                            ? n - 1
                            : null;
                  if (target === null) return;
                  e.preventDefault();
                  (
                    e.currentTarget.parentElement?.querySelectorAll(
                      "rect[role='button']",
                    )[target] as SVGRectElement | undefined
                  )?.focus();
                }}
              />
            ))}
        </svg>
      ) : (
        <div style={{ height }} />
      )}

      {/* Tooltip — instant movement, 100ms fade on appear */}
      {hasData && tipBar && (
        <div
          aria-hidden
          className="pointer-events-none absolute top-0 left-0 z-10 min-w-36 rounded-lg bg-popover px-2.5 py-2 text-xs shadow-overlay transition-opacity duration-100"
          style={{
            opacity: tip.visible ? 1 : 0,
            transform: `translate(${tip.x}px, ${tip.y}px) translate(${
              flip ? "calc(-100% - 14px)" : "14px"
            }, ${flipY ? "calc(-100% - 12px)" : "10px"})`,
          }}
        >
          <div className="flex items-center gap-1.5">
            <span
              className="size-2 shrink-0 rounded-full"
              style={{ background: colorFor(tipBar) }}
            />
            <span className="text-muted-foreground">{tipBar.label}</span>
          </div>
          <div className="mt-1.5 flex flex-col gap-1 border-t pt-1.5">
            {tipBar.kind === "delta" && (
              <div className="flex items-center gap-1.5">
                <span className="text-muted-foreground">Change</span>
                <span
                  className={cn(
                    "ml-auto pl-3 font-medium tabular-nums",
                    tipBar.delta >= 0 ? "text-success" : "text-destructive",
                  )}
                >
                  {signedFormat(tipBar.delta)}
                </span>
              </div>
            )}
            <div className="flex items-center gap-1.5">
              <span className="text-muted-foreground">
                {tipBar.kind === "delta" ? "Running total" : "Total"}
              </span>
              <span className="ml-auto pl-3 font-medium tabular-nums">
                {formatValue(tipBar.to)}
              </span>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}