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.
npx shadcn@latest add @paragon/waterfall-chart"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>
);
}