"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
export interface SankeyNode {
id: string;
label: string;
/** Optional colour; otherwise the accent, then a categorical palette, in data order. */
color?: string;
}
export interface SankeyLink {
source: string;
target: string;
value: number;
}
export interface SankeyData {
nodes: SankeyNode[];
links: SankeyLink[];
}
export type SankeyLinkColor = "gradient" | "source" | "target" | "neutral";
export type SankeyAlign = "justify" | "left";
export interface SankeyDiagramProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
data: SankeyData;
/** Height of the diagram in px; the width follows the container. */
height?: number;
/** Width of each node bar in px. */
nodeWidth?: number;
/** Vertical gap between nodes in a column, in px (shrinks if a column is crowded). */
nodePadding?: number;
/** How links are coloured: a source→target gradient, either end's colour, or neutral ink. */
linkColor?: SankeyLinkColor;
/** "justify" pushes nodes without outgoing links to the last column; "left" keeps them at their depth. */
align?: SankeyAlign;
/** Show each node's value beside its label. */
showValues?: boolean;
/** Colour of the first node; the rest use a categorical palette. */
accent?: string;
format?: (value: number) => string;
/** Accessible name of the diagram. */
label?: string;
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* --------------------------------- Layout ---------------------------------- */
interface LNode {
id: string;
label: string;
color: string;
index: number;
sourceLinks: LLink[];
targetLinks: LLink[];
value: number;
inflow: number;
outflow: number;
layer: number;
x0: number;
x1: number;
y0: number;
y1: number;
}
interface LLink {
id: string;
source: LNode;
target: LNode;
value: number;
width: number;
/** Centre of the link's band where it leaves the source / enters the target. */
y0: number;
y1: number;
}
interface Layout {
nodes: LNode[];
links: LLink[];
columns: LNode[][];
/** Flow leaving the nodes that have no inputs. */
total: number;
}
const PALETTE = ["var(--sk-accent)", "#3d8bea", "#22b07d", "#f0b429", "#e87ba4", "#8b7cf0", "#4fb9c9", "#a3a3a3"];
const ITERATIONS = 32;
const centre = (n: LNode) => (n.y0 + n.y1) / 2;
/**
* Sankey layout, implemented inline:
* 1. drop invalid links and any link that would close a cycle (found by DFS);
* 2. place nodes in columns by longest path from a source (sinks pushed right when justified);
* 3. scale values so the fullest column fits the height; stack and spread each column;
* 4. relax: repeatedly move nodes toward the value-weighted centre of their neighbours,
* sweeping right-to-left and left-to-right, resolving overlaps after each column;
* 5. order each node's links by the position of the other end and stack their bands.
*/
function layoutSankey(data: SankeyData, width: number, height: number, nodeWidth: number, nodePadding: number, align: SankeyAlign): Layout {
const byId = new Map<string, LNode>();
data.nodes.forEach((n, index) => {
if (byId.has(n.id)) return;
byId.set(n.id, {
id: n.id,
label: n.label,
color: n.color ?? PALETTE[index % PALETTE.length],
index,
sourceLinks: [],
targetLinks: [],
value: 0,
inflow: 0,
outflow: 0,
layer: 0,
x0: 0,
x1: 0,
y0: 0,
y1: 0,
});
});
let nodes = [...byId.values()];
// Valid links only; parallel links between the same pair get distinct ids.
const seen = new Map<string, number>();
const candidates: LLink[] = [];
for (const l of data.links) {
const source = byId.get(l.source);
const target = byId.get(l.target);
if (!source || !target || source === target || !(l.value > 0)) continue;
const key = `${l.source}→${l.target}`;
const dup = seen.get(key) ?? 0;
seen.set(key, dup + 1);
candidates.push({ id: dup ? `${key}#${dup}` : key, source, target, value: l.value, width: 0, y0: 0, y1: 0 });
}
const outgoing = new Map<LNode, LLink[]>(nodes.map((n) => [n, []]));
for (const l of candidates) outgoing.get(l.source)!.push(l);
// DFS: a link back to a node still on the stack would close a cycle — leave it out.
const state = new Map<LNode, 1 | 2>();
const order: LNode[] = [];
const cyclic = new Set<LLink>();
const visit = (n: LNode) => {
state.set(n, 1);
for (const l of outgoing.get(n)!) {
const s = state.get(l.target);
if (s === 1) cyclic.add(l);
else if (!s) visit(l.target);
}
state.set(n, 2);
order.push(n);
};
nodes.forEach((n) => !state.has(n) && visit(n));
const links = candidates.filter((l) => !cyclic.has(l));
for (const l of links) {
l.source.sourceLinks.push(l);
l.target.targetLinks.push(l);
}
// Nodes without any remaining link carry no flow: leave them out of the layout.
nodes = nodes.filter((n) => n.sourceLinks.length || n.targetLinks.length);
if (!nodes.length) return { nodes, links, columns: [], total: 0 };
// Columns by longest path from a source, in topological order.
let maxLayer = 0;
for (const n of order.reverse()) {
n.layer = n.targetLinks.reduce((m, l) => Math.max(m, l.source.layer + 1), 0);
maxLayer = Math.max(maxLayer, n.layer);
}
if (align === "justify") for (const n of nodes) if (!n.sourceLinks.length && n.targetLinks.length) n.layer = maxLayer;
for (const n of nodes) {
n.inflow = n.targetLinks.reduce((s, l) => s + l.value, 0);
n.outflow = n.sourceLinks.reduce((s, l) => s + l.value, 0);
n.value = Math.max(n.inflow, n.outflow);
}
const columns: LNode[][] = Array.from({ length: maxLayer + 1 }, () => []);
for (const n of nodes) columns[n.layer].push(n);
const kx = maxLayer ? (width - nodeWidth) / maxLayer : 0;
for (const n of nodes) {
n.x0 = maxLayer ? n.layer * kx : (width - nodeWidth) / 2;
n.x1 = n.x0 + nodeWidth;
}
// Vertical scale: the fullest column (values + gaps) fits the height.
const crowded = Math.max(1, ...columns.map((c) => c.length));
const py = Math.max(2, Math.min(nodePadding, (height * 0.6) / Math.max(1, crowded - 1)));
const ky = Math.min(
...columns.filter((c) => c.length).map((c) => (height - (c.length - 1) * py) / Math.max(1e-9, c.reduce((s, n) => s + n.value, 0))),
);
for (const l of links) l.width = l.value * ky;
const resolve = (col: LNode[]) => {
col.sort((a, b) => a.y0 - b.y0);
let y = 0;
for (const n of col) {
const dy = y - n.y0;
if (dy > 0) {
n.y0 += dy;
n.y1 += dy;
}
y = n.y1 + py;
}
y = height;
for (let i = col.length - 1; i >= 0; i--) {
const n = col[i];
const dy = n.y1 - y;
if (dy > 0) {
n.y0 -= dy;
n.y1 -= dy;
}
y = n.y0 - py;
}
};
// Initial stack, spreading each column's spare height evenly between its nodes.
for (const col of columns) {
let y = 0;
for (const n of col) {
n.y0 = y;
n.y1 = y + n.value * ky;
y = n.y1 + py;
}
const spare = (height - (y - py)) / (col.length + 1);
col.forEach((n, i) => {
n.y0 += spare * (i + 1);
n.y1 += spare * (i + 1);
});
}
const relax = (col: LNode[], pick: (n: LNode) => LLink[], other: (l: LLink) => LNode, alpha: number) => {
for (const n of col) {
const ls = pick(n);
if (!ls.length) continue;
let sum = 0;
let weight = 0;
for (const l of ls) {
sum += centre(other(l)) * l.value;
weight += l.value;
}
const dy = (sum / weight - centre(n)) * alpha;
n.y0 += dy;
n.y1 += dy;
}
resolve(col);
};
for (let i = 0; i < ITERATIONS; i++) {
const alpha = Math.pow(0.99, i) * 0.9;
for (let c = columns.length - 2; c >= 0; c--) relax(columns[c], (n) => n.sourceLinks, (l) => l.target, alpha);
for (let c = 1; c < columns.length; c++) relax(columns[c], (n) => n.targetLinks, (l) => l.source, alpha);
}
// Stack link bands at each node, ordered by where their other end sits, to minimise crossings.
for (const n of nodes) {
n.sourceLinks.sort((a, b) => a.target.y0 - b.target.y0 || a.target.index - b.target.index);
n.targetLinks.sort((a, b) => a.source.y0 - b.source.y0 || a.source.index - b.source.index);
let y = n.y0;
for (const l of n.sourceLinks) {
l.y0 = y + l.width / 2;
y += l.width;
}
y = n.y0;
for (const l of n.targetLinks) {
l.y1 = y + l.width / 2;
y += l.width;
}
}
const total = nodes.filter((n) => !n.targetLinks.length).reduce((s, n) => s + n.outflow, 0);
for (const col of columns) col.sort((a, b) => a.y0 - b.y0);
return { nodes, links, columns, total };
}
const r2 = (n: number) => Math.round(n * 100) / 100;
/** A horizontal cubic Bézier from the source's right edge to the target's left edge. */
function linkPath(l: LLink) {
const x0 = r2(l.source.x1);
const x1 = r2(l.target.x0);
const xm = r2((x0 + x1) / 2);
const y0 = r2(l.y0);
// A perfectly flat path has a zero-height bounding box, which hides bounding-box gradients.
const y1 = Math.abs(l.y1 - l.y0) < 0.01 ? y0 + 0.01 : r2(l.y1);
return `M${x0},${y0}C${xm},${y0} ${xm},${y1} ${x1},${y1}`;
}
/** Everything upstream and downstream of a node, or a single link and its two ends. */
function flowOf(layout: Layout, active: { kind: "node" | "link"; id: string } | null) {
if (!active) return null;
const nodes = new Set<string>();
const links = new Set<string>();
if (active.kind === "link") {
const l = layout.links.find((x) => x.id === active.id);
if (l) {
links.add(l.id);
nodes.add(l.source.id).add(l.target.id);
}
return { nodes, links };
}
const start = layout.nodes.find((n) => n.id === active.id);
if (!start) return null;
const walk = (n: LNode, dir: "up" | "down") => {
nodes.add(n.id);
for (const l of dir === "down" ? n.sourceLinks : n.targetLinks) {
if (links.has(l.id)) continue;
links.add(l.id);
walk(dir === "down" ? l.target : l.source, dir);
}
};
walk(start, "down");
walk(start, "up");
return { nodes, links };
}
const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 });
const pct = (part: number, whole: number) => `${whole > 0 ? ((part / whole) * 100).toFixed(1) : "0"}%`;
/* -------------------------------- Component -------------------------------- */
/**
* A Sankey flow diagram with an inline layout (columns by depth, iterative relaxation, link
* widths by value, Bézier links). Hover or focus a node to trace everything flowing into and
* out of it, or hover a link for its value. Every node is a tab stop; arrow keys move within
* a column and follow the largest flow across columns. A visually hidden table lists every link.
* Data changes animate nodes and links to their new places.
*/
export function SankeyDiagram({
data,
height = 380,
nodeWidth = 12,
nodePadding = 14,
linkColor = "gradient",
align = "justify",
showValues = true,
accent = "#FF7448",
format,
label = "Flow diagram",
className = "",
style,
...props
}: SankeyDiagramProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
const hintId = `${uid}-hint`;
const boxRef = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(720);
const [active, setActive] = React.useState<{ kind: "node" | "link"; id: string } | null>(null);
const [tipHidden, setTipHidden] = React.useState(false);
const fmt = format ?? ((v: number) => compact.format(v));
// Animate only when the data changes — not on resizes or the first measurement.
const [prevData, setPrevData] = React.useState(data);
const [animating, setAnimating] = React.useState(false);
if (data !== prevData) {
setPrevData(data);
setAnimating(true);
}
React.useEffect(() => {
const box = boxRef.current;
if (!box) return;
const ro = new ResizeObserver((entries) => {
setWidth(Math.max(1, Math.round(entries[0].contentRect.width)));
setAnimating(false);
});
ro.observe(box);
return () => ro.disconnect();
}, []);
const W = Math.max(120, width);
const H = Math.max(80, height);
const nw = Math.max(2, nodeWidth);
const layout = layoutSankey(data, W, H, nw, Math.max(0, nodePadding), align);
const maxLayer = layout.columns.length - 1;
const flow = flowOf(layout, active);
const transition = animating && !reduceMotion ? { type: "spring" as const, stiffness: 140, damping: 22, mass: 1 } : { duration: 0 };
const focusNode = (id: string) => boxRef.current?.querySelector<SVGElement>(`[data-sk-node="${CSS.escape(id)}"]`)?.focus();
const onNodeKeyDown = (event: React.KeyboardEvent, node: LNode) => {
const col = layout.columns[node.layer];
const i = col.indexOf(node);
let next: LNode | undefined;
if (event.key === "ArrowDown") next = col[i + 1];
else if (event.key === "ArrowUp") next = col[i - 1];
else if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
const right = event.key === "ArrowRight";
// Follow the largest flow in that direction; otherwise jump to the nearest node in the next column.
const ls = right ? node.sourceLinks : node.targetLinks;
const best = [...ls].sort((a, b) => b.value - a.value)[0];
next = best ? (right ? best.target : best.source) : undefined;
if (!next) {
for (let c = node.layer + (right ? 1 : -1); c >= 0 && c <= maxLayer && !next; c += right ? 1 : -1) {
next = [...layout.columns[c]].sort((a, b) => Math.abs(centre(a) - centre(node)) - Math.abs(centre(b) - centre(node)))[0];
}
}
} else if (event.key === "Home") next = layout.columns.flat()[0];
else if (event.key === "End") next = layout.columns.flat().at(-1);
else if (event.key === "Escape" && active && !tipHidden) {
// Dismiss only the tooltip; let Escape through when there is nothing to close.
event.preventDefault();
event.stopPropagation();
setTipHidden(true);
return;
} else return;
event.preventDefault();
if (next) focusNode(next.id);
};
const show = (next: { kind: "node" | "link"; id: string } | null) => {
setActive(next);
setTipHidden(false);
};
const nodeName = (n: LNode) => {
const parts = [`${n.label}: ${fmt(n.value)}`];
if (n.targetLinks.length) parts.push(`in ${fmt(n.inflow)} from ${n.targetLinks.length} ${n.targetLinks.length === 1 ? "source" : "sources"}`);
if (n.sourceLinks.length) parts.push(`out ${fmt(n.outflow)} to ${n.sourceLinks.length} ${n.sourceLinks.length === 1 ? "target" : "targets"}`);
if (layout.total) parts.push(`${pct(n.value, layout.total)} of total flow`);
return parts.join(", ");
};
const strokeFor = (l: LLink) =>
linkColor === "gradient" ? `url(#${uid}-g-${l.source.index}-${l.target.index})` : linkColor === "source" ? l.source.color : linkColor === "target" ? l.target.color : "currentColor";
/* --------------------------------- tooltip -------------------------------- */
const activeNode = active?.kind === "node" ? layout.nodes.find((n) => n.id === active.id) : undefined;
const activeLink = active?.kind === "link" ? layout.links.find((l) => l.id === active.id) : undefined;
// The tooltip sits above (or below) what it describes, so it never covers the node's label.
// A node as tall as the chart gets it beside instead.
type Tip = { title: string; lines: string[]; style: React.CSSProperties; beside: boolean };
const ROOM = 96;
const place = (cx: number, top: number, bottom: number, besideX: number, besideRight: boolean): Pick<Tip, "style" | "beside"> => {
const left = Math.min(W - 110, Math.max(110, cx));
if (top >= ROOM) return { style: { left, bottom: H - top + 8 }, beside: false };
if (H - bottom >= ROOM) return { style: { left, top: bottom + 8 }, beside: false };
const y = Math.min(H - 40, Math.max(40, (top + bottom) / 2));
return { style: besideRight ? { left: besideX + 10, top: y } : { right: W - besideX + 10, top: y }, beside: true };
};
let tip: Tip | null = null;
if (activeNode && !tipHidden) {
const right = activeNode.layer < maxLayer / 2 || maxLayer === 0;
tip = {
title: activeNode.label,
lines: [
activeNode.targetLinks.length ? `In · ${fmt(activeNode.inflow)}` : "",
activeNode.sourceLinks.length ? `Out · ${fmt(activeNode.outflow)}` : "",
layout.total ? `${pct(activeNode.value, layout.total)} of total` : "",
].filter(Boolean),
...place((activeNode.x0 + activeNode.x1) / 2, activeNode.y0, activeNode.y1, right ? activeNode.x1 : activeNode.x0, right),
};
} else if (activeLink && !tipHidden) {
const x = (activeLink.source.x1 + activeLink.target.x0) / 2;
const y = (activeLink.y0 + activeLink.y1) / 2;
tip = {
title: `${activeLink.source.label} → ${activeLink.target.label}`,
lines: [fmt(activeLink.value), `${pct(activeLink.value, activeLink.source.outflow)} of ${activeLink.source.label}`],
...place(x, y - activeLink.width / 2, y + activeLink.width / 2, x, x < W / 2),
};
}
const dim = (on: boolean) => (flow ? (on ? 1 : 0.25) : 1);
return (
<div
className={`flex w-full min-w-0 flex-col gap-2 text-zinc-950 dark:text-zinc-50 ${className}`}
style={{ ["--sk-accent" as string]: accent, ...style }}
{...props}
>
<div ref={boxRef} className="relative w-full" style={{ height: H }}>
<svg
width={W}
height={H}
viewBox={`0 0 ${W} ${H}`}
role="group"
aria-label={label}
aria-describedby={hintId}
className="block overflow-visible"
onPointerLeave={(e) => e.pointerType === "mouse" && show(null)}
>
<defs>
{linkColor === "gradient" &&
layout.links.map((l) => (
<linearGradient key={l.id} id={`${uid}-g-${l.source.index}-${l.target.index}`}>
<stop offset="0%" stopColor={l.source.color} />
<stop offset="100%" stopColor={l.target.color} />
</linearGradient>
))}
</defs>
<g fill="none" className="text-zinc-400 dark:text-zinc-500">
<AnimatePresence initial={false}>
{layout.links.map((l) => {
const on = !flow || flow.links.has(l.id);
return (
<motion.path
key={l.id}
aria-hidden="true"
initial={{ opacity: 0 }}
animate={{ d: linkPath(l), strokeWidth: r2(Math.max(1, l.width)), opacity: 1 }}
exit={{ opacity: 0, transition: { duration: reduceMotion ? 0 : 0.2 } }}
transition={transition}
stroke={strokeFor(l)}
strokeOpacity={flow ? (on ? 0.62 : 0.06) : linkColor === "neutral" ? 0.4 : 0.34}
className="cursor-default transition-[stroke-opacity] duration-200 motion-reduce:transition-none"
onPointerEnter={(e) => e.pointerType === "mouse" && show({ kind: "link", id: l.id })}
onClick={() => show(active?.kind === "link" && active.id === l.id ? null : { kind: "link", id: l.id })}
/>
);
})}
</AnimatePresence>
</g>
<g role="list" aria-label={`${label} nodes`}>
<AnimatePresence initial={false}>
{layout.columns.flat().map((n) => {
const h = r2(Math.max(1, n.y1 - n.y0));
const labelRight = n.layer < maxLayer || maxLayer === 0;
return (
<motion.g
key={n.id}
className="group"
initial={{ opacity: 0 }}
animate={{ x: r2(n.x0), y: r2(n.y0), opacity: dim(!flow || flow.nodes.has(n.id)) }}
exit={{ opacity: 0, transition: { duration: reduceMotion ? 0 : 0.2 } }}
transition={transition}
>
{/* Focus ring, keyboard focus only. */}
<motion.rect
aria-hidden="true"
x={-3}
y={-3}
width={nw + 6}
rx={4}
initial={false}
animate={{ height: r2(h + 6) }}
transition={transition}
fill="none"
strokeWidth={2}
className="pointer-events-none stroke-zinc-950 opacity-0 group-has-[:focus-visible]:opacity-100 dark:stroke-white"
/>
<motion.rect
data-sk-node={n.id}
role="listitem"
aria-label={nodeName(n)}
tabIndex={0}
width={nw}
rx={2}
initial={false}
animate={{ height: h }}
transition={transition}
fill={n.color}
className="cursor-default outline-none"
onPointerEnter={(e) => e.pointerType === "mouse" && show({ kind: "node", id: n.id })}
onClick={() => show(active?.kind === "node" && active.id === n.id ? null : { kind: "node", id: n.id })}
onFocus={() => show({ kind: "node", id: n.id })}
onBlur={() => show(null)}
onKeyDown={(e) => onNodeKeyDown(e, n)}
/>
<motion.text
aria-hidden="true"
initial={false}
animate={{ y: r2(h / 2) }}
transition={transition}
x={labelRight ? nw + 6 : -6}
dy="0.35em"
textAnchor={labelRight ? "start" : "end"}
className="pointer-events-none fill-current text-[11px] font-medium tracking-[-0.01em] [paint-order:stroke] [stroke-linejoin:round] [stroke-width:3px] stroke-white/70 dark:stroke-black/50"
>
{n.label}
{showValues && W >= 480 && (
<tspan className="fill-zinc-500 font-mono text-[10px] font-normal dark:fill-zinc-400" dx={5}>
{fmt(n.value)}
</tspan>
)}
</motion.text>
</motion.g>
);
})}
</AnimatePresence>
</g>
</svg>
{tip && (
<div
aria-hidden="true"
className={`pointer-events-none absolute z-10 w-max max-w-[220px] rounded-md border border-zinc-900/10 bg-white/95 px-2.5 py-2 shadow-[0_10px_30px_-12px_rgb(0_0_0/0.4)] dark:border-white/10 dark:bg-zinc-900/95 ${
tip.beside ? "-translate-y-1/2" : "-translate-x-1/2"
}`}
style={tip.style}
>
<span className="block text-sm font-semibold tracking-[-0.01em]">{tip.title}</span>
{tip.lines.map((line) => (
<span key={line} className="mt-0.5 block font-mono text-[11px] tabular-nums text-zinc-600 dark:text-zinc-300">
{line}
</span>
))}
</div>
)}
</div>
<p id={hintId} className="sr-only">
Tab or the arrow keys move between nodes: up and down within a column, left and right along the largest flow. Escape hides the details.
</p>
<table className="sr-only">
<caption>{label}: links (source → target: value)</caption>
<thead>
<tr>
<th scope="col">Source</th>
<th scope="col">Target</th>
<th scope="col">Value</th>
</tr>
</thead>
<tbody>
{layout.links.map((l) => (
<tr key={l.id}>
<td>{l.source.label}</td>
<td>{l.target.label}</td>
<td>{fmt(l.value)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}