Sankey diagram
Flows between stages, each band as wide as its value. A budget from sources to uses, visitor paths with exits, energy from source to use with losses and a before and after of plans. Picking a node lights up every flow through it.
Where everything flows
A sankey diagram shows flows between stages with bands as wide as their value, so the big streams and the small leaks are visible at once. The pointer and the arrow keys light up every flow through a node, so one path can be followed through the whole chart.
Budget lets four sources flow into one budget and out to five uses, the pointer on a node lights up its whole path. Visitor journey runs from the entry over the first page to the outcome, exits stay grey and hatched and every node carries its share of all visits. Energy flow follows a house with solar roof and battery over a year, each band keeps the color of the source it starts from and losses are hatched. Before and after shows customers by plan in two quarters with labels outside, each band keeps the color of its plan and cancellations are hatched.
Keep the stages to three or four, more columns turn the flows into spaghetti. Color by origin, then people can follow a source all the way through. Hatch losses and exits, they are often the reason the chart exists.
Variants
Details
What Sankey chart brings along, read from the registry and the source.
- Category
- Comparison charts
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/sankey-chart.tsx
- Brings along
- utilskitchartchart-figure
- npm packages
- None
- Languages
- English and German, 10 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The SankeyChartProps interface from components/ui/sankey-chart.tsx, with the comments from the code. Also takes every prop of Omit<ChartA11y, 'label'>, ChartFigureProps.
labeloptionalstringAccessible name of the chart. Default: the title.
variantoptionalSankeyVariantbudgetjourneyenergymigration
nodesrequiredSankeyNode[]linksrequiredSankeyLink[]formatoptionalIntl.NumberFormatOptionsValue format, e.g. { style: 'currency', currency: 'EUR' }
suffixoptionalstringUnit written after every value, for units Intl does not know, e.g. "kWh"
stagesoptionalstring[]Names of the stages, shown above the columns
heightoptionalnumberPlot height in px. Default 380, 460 on phones.
Built-in texts
Sankey chart writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 10 texts
| Key | English | German |
|---|---|---|
| from | English From | German Von |
| to | English To | German Nach |
| value | English Value | German Wert |
| share | English Share | German Anteil |
| ofSource | English of {node} | German von {node} |
| ofAll | English of all | German von allem |
| inflow | English In | German Zufluss |
| outflow | English Out | German Abfluss |
| summary | English {n} flows between {s} stages, {total} in the first stage. | German {n} Flüsse über {s} Stufen, {total} in der ersten Stufe. |
| node | English {label}, {value}, {share} of all | German {label}, {value}, {share} von allem |
Accessibility
Read from the source of the component.
- Sankey 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
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
- 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, 480 lines
'use client';
import { useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { CHART_TEXTS, ChartRoot, ChartSurface, ChartTable, ChartTooltip, Swatch, fill, numberFormatter, seriesColor, textWidth, useEntrance, useIntlLocale, useShown, useWidth, type ChartA11y, type TooltipRow } from '@/components/ui/chart';
import { ChartFigure, HALO, type ChartFigureProps } from '@/components/ui/chart-figure';
/*
* Flows between stages, each band as wide as its value.
* budget money from its sources into a budget and out to its uses,
* neutral bands, the path through a node lights up
* journey visitor paths from entry to outcome, exits in grey, every
* node with its share of all visits
* energy sources, conversion and use, each band in the color of the
* source it starts from, losses hatched
* migration two columns, before and after (plans, tariffs, channels),
* labels outside, the bands in the color of where they came from
*
* Pointer and keyboard pick a node and light up every flow that passes
* through it, up to the first stage and down to the last. Arrow keys move
* up and down within a stage and left and right between the stages.
*/
export type SankeyVariant = 'budget' | 'journey' | 'energy' | 'migration';
export interface SankeyNode {
id: string;
label: string;
/** The stage, 0 is the first. Default: the longest path from a first node. */
column?: number;
/** loss: drawn hatched and grey (waste, exits, cancellations) */
kind?: 'loss';
}
export interface SankeyLink {
source: string;
target: string;
value: number;
}
export interface SankeyChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
/** Accessible name of the chart. Default: the title. */
label?: string;
variant?: SankeyVariant;
nodes: SankeyNode[];
links: SankeyLink[];
/** Value format, e.g. { style: 'currency', currency: 'EUR' } */
format?: Intl.NumberFormatOptions;
/** Unit written after every value, for units Intl does not know, e.g. "kWh" */
suffix?: string;
/** Names of the stages, shown above the columns */
stages?: string[];
/** Plot height in px. Default 380, 460 on phones. */
height?: number;
className?: string;
}
const TEXTS = {
en: { from: 'From', to: 'To', value: 'Value', share: 'Share', ofSource: 'of {node}', ofAll: 'of all', inflow: 'In', outflow: 'Out', summary: '{n} flows between {s} stages, {total} in the first stage.', node: '{label}, {value}, {share} of all' },
de: { from: 'Von', to: 'Nach', value: 'Wert', share: 'Anteil', ofSource: 'von {node}', ofAll: 'von allem', inflow: 'Zufluss', outflow: 'Abfluss', summary: '{n} Flüsse über {s} Stufen, {total} in der ersten Stufe.', node: '{label}, {value}, {share} von allem' },
};
type Pos = { x: number; y: number; h: number; value: number; col: number };
/**
* A Sankey diagram: budget, visitor journey, energy flow or a before and after.
* <SankeyChart variant="budget" nodes={[{ id: 'fees', label: 'Fees' }, …]} links={[{ source: 'fees', target: 'budget', value: 420000 }]} />
*/
export function SankeyChart(props: SankeyChartProps) {
const { variant = 'budget', nodes, links: rawLinks, format, suffix, stages, height: heightProp, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
const t = useTexts(TEXTS, undefined);
useTexts(CHART_TEXTS, labels);
const intl = useIntlLocale();
const nf = numberFormatter(intl, format);
const fmt = (n: number) => (suffix ? `${nf(n)} ${suffix}` : nf(n));
const pct = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 1 });
const [wrapRef, width] = useWidth<HTMLDivElement>(760);
const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
const [active, setActive] = useState<number | null>(null);
const [link, setLink] = useState<number | null>(null);
const { shown, open, jump } = useShown(active ?? (link !== null ? -1 - link : null));
const phone = width < 560;
const height = heightProp ?? (phone ? 460 : 380);
const name = label ?? title ?? '';
const delay = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);
// Graph
const index = new Map(nodes.map((nd, i) => [nd.id, i]));
const links = rawLinks.filter((l) => index.has(l.source) && index.has(l.target) && l.value > 0);
const src = links.map((l) => index.get(l.source)!);
const dst = links.map((l) => index.get(l.target)!);
const outOf = nodes.map((_, i) => links.map((_, k) => k).filter((k) => src[k] === i));
const inTo = nodes.map((_, i) => links.map((_, k) => k).filter((k) => dst[k] === i));
const col: number[] = nodes.map((nd) => nd.column ?? -1);
// Longest path from a first node. `path` holds the nodes on the current path only, so a
// cycle stops while a node reached on two routes still counts its longer one.
const memo = new Map<number, number>();
const depth = (i: number, path: Set<number>): number => {
if (nodes[i].column !== undefined) return nodes[i].column!;
if (memo.has(i)) return memo.get(i)!;
if (path.has(i)) return 0;
path.add(i);
const ins = inTo[i];
const d = ins.length ? Math.max(...ins.map((k) => depth(src[k], path) + 1)) : 0;
path.delete(i);
memo.set(i, d);
return d;
};
nodes.forEach((_, i) => (col[i] = depth(i, new Set())));
const C = Math.max(1, ...col.map((c) => c + 1));
const valueOf = nodes.map((_, i) => Math.max(inTo[i].reduce((a, k) => a + links[k].value, 0), outOf[i].reduce((a, k) => a + links[k].value, 0)));
const columns = Array.from({ length: C }, (_, c) => nodes.map((_, i) => i).filter((i) => col[i] === c));
const stageTotal = columns.map((ids) => ids.reduce((a, i) => a + valueOf[i], 0));
const firstTotal = stageTotal[0] || 1;
// Roots for colors: nodes of the first stage
const rootSlot = new Map(columns[0].filter((i) => nodes[i].kind !== 'loss').map((i, k) => [i, k]));
const colored = variant === 'energy' || variant === 'migration';
const rootOfLink = (k: number): number => {
let i = src[k];
const seen = new Set<number>();
while (col[i] > 0 && inTo[i].length && !seen.has(i)) {
seen.add(i);
// follow the largest inflow back to the first stage
i = src[[...inTo[i]].sort((a, b) => links[b].value - links[a].value)[0]];
}
return i;
};
const nodeColor = (i: number) => (nodes[i].kind === 'loss' ? 'var(--muted-foreground)' : colored ? (slotOf(i) !== undefined ? seriesColor(slotOf(i)!) : 'color-mix(in oklab, var(--foreground) 72%, var(--card))') : 'var(--kitch-1)');
/** A later node with the same name as a first-stage node (the same plan after the move) shares its color */
function slotOf(i: number) {
if (rootSlot.has(i)) return rootSlot.get(i);
const twin = [...rootSlot.keys()].find((r) => nodes[r].label === nodes[i].label);
return twin === undefined ? undefined : rootSlot.get(twin);
}
const linkBase = (k: number) => {
if (nodes[dst[k]].kind === 'loss') return 'color-mix(in oklab, var(--muted-foreground) 16%, transparent)';
if (colored) {
const r = variant === 'energy' ? rootOfLink(k) : src[k];
if (rootSlot.has(r)) return `color-mix(in oklab, ${seriesColor(rootSlot.get(r)!)} 42%, transparent)`;
}
return 'color-mix(in oklab, var(--foreground) 13%, transparent)';
};
const LIT = 'color-mix(in oklab, var(--primary) 58%, transparent)';
// Layout
const labelOut = !phone;
const labelText = (i: number) => nodes[i].label;
const outerW = (c: number) => (labelOut && (c === 0 || c === C - 1) ? Math.min(Math.round(width * 0.28), Math.max(...columns[c].map((i) => Math.max(textWidth(labelText(i), 12), textWidth(fmt(valueOf[i]) + (variant === 'journey' ? ' · ' + pct(valueOf[i] / firstTotal) : ''), 11))), 0) + 14) : 0);
const nodeW = phone ? 10 : 12;
const left = outerW(0);
const right = width - (C > 1 ? outerW(C - 1) : 0);
const top = stages?.length ? 30 : 6;
const bottom = height - 6;
const pad = phone ? 12 : 14;
const k = Math.min(...columns.map((ids, c) => (bottom - top - pad * Math.max(0, ids.length - 1)) / (stageTotal[c] || 1)));
const colX = (c: number) => round(C > 1 ? left + (c / (C - 1)) * (right - left - nodeW) : left);
const pos: Pos[] = nodes.map(() => ({ x: 0, y: 0, h: 0, value: 0, col: 0 }));
columns.forEach((ids, c) => {
const used = ids.reduce((a, i) => a + valueOf[i] * k, 0) + pad * Math.max(0, ids.length - 1);
let y = top + (bottom - top - used) / 2;
ids.forEach((i) => {
const h = Math.max(2, valueOf[i] * k);
pos[i] = { x: colX(c), y: round(y), h: round(h), value: valueOf[i], col: c };
y += h + pad;
});
});
// Band ends: outgoing sorted by target position, incoming by source position
const sOff = new Array(links.length).fill(0);
const tOff = new Array(links.length).fill(0);
nodes.forEach((_, i) => {
let a = 0;
[...outOf[i]].sort((p, q) => pos[dst[p]].y - pos[dst[q]].y).forEach((kk) => {
sOff[kk] = a;
a += links[kk].value * k;
});
let b = 0;
[...inTo[i]].sort((p, q) => pos[src[p]].y - pos[src[q]].y).forEach((kk) => {
tOff[kk] = b;
b += links[kk].value * k;
});
});
// Label heights per column: pushed apart where small nodes sit close, kept inside the plot
const labelY: number[] = pos.map((p) => p.y + p.h / 2);
columns.forEach((ids) => {
const gapL = 31;
for (let j = 1; j < ids.length; j++) labelY[ids[j]] = Math.max(labelY[ids[j]], labelY[ids[j - 1]] + gapL);
const over = labelY[ids[ids.length - 1]] - (bottom - 12);
if (over > 0) {
labelY[ids[ids.length - 1]] -= over;
for (let j = ids.length - 2; j >= 0; j--) labelY[ids[j]] = Math.min(labelY[ids[j]], labelY[ids[j + 1]] - gapL);
}
});
const band = (kk: number) => {
const s = pos[src[kk]];
const d = pos[dst[kk]];
const w = Math.max(1, links[kk].value * k);
const x0 = s.x + nodeW;
const x1 = d.x;
const y0 = s.y + sOff[kk];
const y1 = d.y + tOff[kk];
const xm = (x0 + x1) / 2;
const r = (n: number) => round(n);
return `M${r(x0)} ${r(y0)}C${r(xm)} ${r(y0)} ${r(xm)} ${r(y1)} ${r(x1)} ${r(y1)}L${r(x1)} ${r(y1 + w)}C${r(xm)} ${r(y1 + w)} ${r(xm)} ${r(y0 + w)} ${r(x0)} ${r(y0 + w)}Z`;
};
// The path through a node: every flow upstream and downstream
const pathOf = (i: number) => {
const lit = new Set<number>();
const litNodes = new Set<number>([i]);
const up = (n: number) => inTo[n].forEach((kk) => !lit.has(kk) && (lit.add(kk), litNodes.add(src[kk]), up(src[kk])));
const down = (n: number) => outOf[n].forEach((kk) => !lit.has(kk) && (lit.add(kk), litNodes.add(dst[kk]), down(dst[kk])));
up(i);
down(i);
return { lit, litNodes };
};
const hot = active !== null ? pathOf(active) : link !== null ? { lit: new Set([link]), litNodes: new Set([src[link], dst[link]]) } : null;
// Keyboard: up and down within a stage, left and right between stages
const onKeyDown = (e: React.KeyboardEvent) => {
setLink(null);
if (e.key === 'Escape') return setActive(null);
const keys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'];
if (!keys.includes(e.key)) return;
e.preventDefault();
if (active === null) return setActive(columns[0][0] ?? null);
const c = col[active];
const at = columns[c].indexOf(active);
if (e.key === 'ArrowDown') return setActive(columns[c][Math.min(columns[c].length - 1, at + 1)]);
if (e.key === 'ArrowUp') return setActive(columns[c][Math.max(0, at - 1)]);
if (e.key === 'Home') return setActive(columns[0][0]);
if (e.key === 'End') return setActive(columns[C - 1][columns[C - 1].length - 1]);
const nc = e.key === 'ArrowRight' ? Math.min(C - 1, c + 1) : Math.max(0, c - 1);
if (nc === c) return;
const mid = pos[active].y + pos[active].h / 2;
// Prefer a node connected to this one, else the nearest by height
const linked = columns[nc].filter((j) => (nc > c ? outOf[active].some((kk) => dst[kk] === j) : inTo[active].some((kk) => src[kk] === j)));
const pool = linked.length ? linked : columns[nc];
setActive([...pool].sort((a, b) => Math.abs(pos[a].y + pos[a].h / 2 - mid) - Math.abs(pos[b].y + pos[b].h / 2 - mid))[0]);
};
// Readouts
const nodeRows = (i: number): TooltipRow[] => {
const out: TooltipRow[] = [{ key: 'v', label: t.value, value: fmt(valueOf[i]) }, { key: 's', label: t.share, value: `${pct(valueOf[i] / firstTotal)} ${t.ofAll}` }];
const ins = [...inTo[i]].sort((a, b) => links[b].value - links[a].value).slice(0, 3);
const outs = [...outOf[i]].sort((a, b) => links[b].value - links[a].value).slice(0, 3);
ins.forEach((kk) => out.push({ key: `i${kk}`, label: `← ${nodes[src[kk]].label}`, value: fmt(links[kk].value) }));
outs.forEach((kk) => out.push({ key: `o${kk}`, label: `→ ${nodes[dst[kk]].label}`, value: fmt(links[kk].value) }));
return out;
};
const linkRows = (kk: number): TooltipRow[] => [
{ key: 'v', label: t.value, value: fmt(links[kk].value) },
{ key: 's', label: fill(t.ofSource, { node: nodes[src[kk]].label }), value: pct(links[kk].value / (valueOf[src[kk]] || 1)) },
];
const shownNode = shown !== null && shown >= 0 && nodes[shown] ? shown : null;
const shownLink = shown !== null && shown < 0 && links[-1 - shown] ? -1 - shown : null;
const live = link !== null && links[link] ? `${nodes[src[link]].label} → ${nodes[dst[link]].label}, ${fmt(links[link].value)}` : active !== null && nodes[active] ? `${fill(t.node, { label: nodes[active].label, value: fmt(valueOf[active]), share: pct(valueOf[active] / firstTotal) })}. ${[...inTo[active].map((kk) => `${t.from} ${nodes[src[kk]].label} ${fmt(links[kk].value)}`), ...outOf[active].map((kk) => `${t.to} ${nodes[dst[kk]].label} ${fmt(links[kk].value)}`)].join(', ')}` : '';
const auto = fill(t.summary, { n: String(links.length), s: String(C), total: fmt(stageTotal[0] ?? 0) });
const table = <ChartTable caption={name} head={[t.from, t.to, t.value, t.share]} rows={links.map((l, kk) => [nodes[src[kk]].label, nodes[dst[kk]].label, fmt(l.value), `${pct(l.value / (valueOf[src[kk]] || 1))} ${fill(t.ofSource, { node: nodes[src[kk]].label })}`])} />;
let tipX = 0;
let tipY = 0;
let tipTitle = '';
let tipRows: TooltipRow[] = [];
if (shownNode !== null) {
const p = pos[shownNode];
tipX = p.col === C - 1 ? p.x : p.x + nodeW;
tipY = Math.max(0, Math.min(p.y + p.h / 2 - 30, height - 140));
tipTitle = nodes[shownNode].label;
tipRows = nodeRows(shownNode);
} else if (shownLink !== null) {
const s = pos[src[shownLink]];
const d = pos[dst[shownLink]];
tipX = round((s.x + nodeW + d.x) / 2);
tipY = Math.max(0, round((s.y + sOff[shownLink] + d.y + tOff[shownLink]) / 2) - 30);
tipTitle = `${nodes[src[shownLink]].label} → ${nodes[dst[shownLink]].label}`;
tipRows = linkRows(shownLink);
}
const maxLabel = (c: number) => {
if (labelOut && (c === 0 || c === C - 1)) return outerW(c) - 12;
const gap = C > 1 ? (right - left - nodeW) / (C - 1) - nodeW : width;
// Phones: the first and last stage share the gap only when there are two stages
// The middle stages are listed below the plot on phones, so the outer labels share the whole width between them
if (phone && (c === 0 || c === C - 1)) return Math.max(40, gap * (C === 2 ? 0.46 : (C - 1) / 2) - 8);
// Middle labels may run over the bands (they carry a halo), so a word like "Wärmepumpe" stays whole on tablets
return Math.max(40, gap * (c === C - 1 ? 0.92 : 0.8) - 10);
};
/** Room for a label of three lines: the neighbours in the column sit at least 45 px away */
const roomy = (i: number) => {
const col = columns[pos[i].col];
const j = col.indexOf(i);
return (j <= 0 || labelY[i] - labelY[col[j - 1]] >= 45) && (j >= col.length - 1 || labelY[col[j + 1]] - labelY[i] >= 45) && labelY[i] - 21 >= top && labelY[i] + 21 <= height;
};
const wrapLabel = (s: string, w: number, room: boolean): string[] => {
if (textWidth(s, 12) <= w || !room || !s.includes(' ')) return [clip(s, w)];
const words = s.split(' ');
let first = words[0];
let k = 1;
while (k < words.length && textWidth(`${first} ${words[k]}`, 12) <= w) first += ` ${words[k++]}`;
return k >= words.length ? [first] : [clip(first, w), clip(words.slice(k).join(' '), w)];
};
const clip = (s: string, w: number, size = 12) => {
if (textWidth(s, size) <= w) return s;
let out = s;
while (out.length > 1 && textWidth(`${out}…`, size) > w) out = out.slice(0, -1);
return `${out.trimEnd()}…`;
};
const legendItems = colored ? [...rootSlot.entries()].map(([i, slot]) => ({ label: nodes[i].label, color: seriesColor(slot) })) : [];
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
{legendItems.length > 1 && (
<ul className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12px] text-muted-foreground">
{legendItems.map((it) => (
<li key={it.label} className="flex items-center gap-1.5">
<Swatch color={it.color} />
{it.label}
</li>
))}
</ul>
)}
<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(columns[0][0] ?? null)}
onBlur={() => {
setActive(null);
setLink(null);
}}
onPointerLeave={(e) => {
if (e.pointerType !== 'mouse') return;
setActive(null);
setLink(null);
}}
>
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
<defs>
<pattern id={`sankey-loss-${variant}`} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<rect width="6" height="6" fill="color-mix(in oklab, var(--muted-foreground) 14%, var(--card))" />
<line x1="0" y1="0" x2="0" y2="6" stroke="color-mix(in oklab, var(--muted-foreground) 60%, var(--card))" strokeWidth="2" />
</pattern>
</defs>
{stages?.length ? (
<g className="text-[11px] font-medium uppercase tracking-[0.06em]" fill="var(--muted-foreground)">
{stages.slice(0, C).map((s, c) => (
<text key={c} x={c === 0 ? colX(0) : c === C - 1 ? colX(c) + nodeW : colX(c) + nodeW / 2} y={12} textAnchor={c === 0 ? 'start' : c === C - 1 ? 'end' : 'middle'}>
{clip(s, c === 0 || c === C - 1 ? Math.max(60, (right - left) / C) : (right - left) / C - 8, 11)}
</text>
))}
</g>
) : null}
{/* Bands */}
<g>
{links.map((_, kk) => {
const lit = hot?.lit.has(kk);
return (
<path
key={kk}
d={band(kk)}
fill={lit && !colored ? LIT : linkBase(kk)}
stroke="var(--kitch-surface)"
strokeWidth="0.75"
strokeOpacity="0.7"
opacity={hot ? (lit ? 1 : 0.32) : 1}
onPointerEnter={(e) => {
if (e.pointerType !== 'mouse') return;
setActive(null);
setLink(kk);
}}
onPointerDown={() => {
setActive(null);
setLink(kk);
}}
className={cn('cursor-pointer transition-[opacity,fill] duration-300 motion-reduce:transition-none', anim && 'kitch-clip')}
style={delay(260 + pos[src[kk]].col * 260)}
/>
);
})}
</g>
{/* Nodes */}
{nodes.map((nd, i) => {
const p = pos[i];
const lit = hot?.litNodes.has(i);
const loss = nd.kind === 'loss';
return (
<g key={nd.id} opacity={hot && !lit ? 0.45 : 1} className="transition-opacity duration-300 motion-reduce:transition-none">
<rect
x={p.x}
y={p.y}
width={nodeW}
height={p.h}
rx={3}
fill={loss ? `url(#sankey-loss-${variant})` : lit && !colored ? 'var(--primary)' : nodeColor(i)}
stroke={loss ? 'var(--muted-foreground)' : 'none'}
strokeWidth={loss ? 1 : 0}
strokeDasharray={loss ? '3 2' : undefined}
onPointerEnter={(e) => {
if (e.pointerType !== 'mouse') return;
setLink(null);
setActive(i);
}}
onPointerDown={() => {
setLink(null);
setActive(i);
}}
className={cn('cursor-pointer', anim && 'kitch-grow-y')}
style={delay(80 + p.col * 260)}
/>
{active === i && <rect x={p.x - 3} y={p.y - 3} width={nodeW + 6} height={p.h + 6} rx={5} fill="none" stroke="var(--foreground)" strokeWidth="1.5" />}
</g>
);
})}
{/* Labels: outside on the first and last stage, beside the node in between */}
<g className="pointer-events-none">
{nodes.map((nd, i) => {
const p = pos[i];
const last = p.col === C - 1 && C > 1;
// Phones name the middle stages in the list below the plot
if (phone && C > 2 && p.col > 0 && !last) return null;
const outside = labelOut && (p.col === 0 || last);
const toLeft = outside ? p.col === 0 : last;
const x = toLeft ? p.x - 8 : p.x + nodeW + 8;
const two = p.h >= 26 || !phone;
const cy = labelY[i];
const w = maxLabel(p.col);
// A long name takes a second line where its neighbours leave room, instead of an ellipsis
const lines = wrapLabel(nd.label, w, roomy(i));
const share = variant === 'journey' ? ` · ${pct(valueOf[i] / firstTotal)}` : '';
const lit = hot?.litNodes.has(i);
return (
<g key={nd.id} opacity={hot && !lit ? 0.5 : 1} className={cn('transition-opacity duration-300', anim && 'kitch-fade')} style={delay(520 + p.col * 200)}>
<text x={x} y={(two ? cy - 7 : cy) - (lines.length - 1) * 7} dy="0.34em" textAnchor={toLeft ? 'end' : 'start'} className={cn('text-[12px]', active === i ? 'font-semibold' : 'font-medium')} fill={nd.kind === 'loss' ? 'var(--muted-foreground)' : 'var(--foreground)'} style={HALO}>
{lines.map((ln, li) => (
<tspan key={li} x={x} dy={li ? 14 : undefined}>
{ln}
</tspan>
))}
</text>
{two && (
<text x={x} y={cy + 8 + (lines.length - 1) * 7} dy="0.34em" textAnchor={toLeft ? 'end' : 'start'} className="text-[11px] tabular-nums" fill="var(--muted-foreground)" style={HALO}>
{clip(`${fmt(valueOf[i])}${share}`, w, 11)}
</text>
)}
</g>
);
})}
</g>
</svg>
<ChartTooltip open={open && (shownNode !== null || shownLink !== null)} jump={jump} x={tipX} y={tipY} width={width} offset={12} title={tipTitle} rows={tipRows} />
</ChartSurface>
{phone && C > 2 && (
<dl className="mt-4 grid gap-3 border-t border-border pt-4 text-[12px]">
{columns.slice(1, -1).map((ids, c) => (
<div key={c}>
{stages?.[c + 1] && <dt className="mb-1 font-medium uppercase tracking-[0.06em] text-muted-foreground">{stages[c + 1]}</dt>}
<dd className="flex flex-wrap gap-x-4 gap-y-1">
{ids.map((i) => (
<span key={i} className={cn('tabular-nums text-muted-foreground transition-opacity', hot && !hot.litNodes.has(i) && 'opacity-50')}>
<span className={cn('font-medium', nodes[i].kind === 'loss' ? 'text-muted-foreground' : 'text-foreground')}>{nodes[i].label}</span> {fmt(valueOf[i])}
</span>
))}
</dd>
</div>
))}
</dl>
)}
</div>
</ChartRoot>
</ChartFigure>
);
}Frequent questions
How do I install Sankey chart?
With aiw add @ai-website/sankey-chart. The command puts components/ui/sankey-chart.tsx into your project, the code is yours from then on. It brings along chart and chart-figure. It needs no further npm packages.
Which variants does Sankey chart have?
4 variants, Budget, Visitor journey, Energy flow and Before and after. You pick one with the variant prop, for example variant="journey".
Can I translate the texts of Sankey chart?
Yes. Sankey chart carries 10 texts of its own in English and German, such as “From”, “To” and “Value”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Sankey chart?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Sankey chart with aiw_components and install it with aiw_add @ai-website/sankey-chart. Name the variant in your prompt, for example variant="budget", and the assistant writes your content straight into the props.