Skip to content
UI Kit
Accent color

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.

4 variantsOne of 8 components in Comparison charts

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

BudgetNew in 1.2

Four sources flow into one budget and out to five uses. The pointer on a node lights up its whole path.

Visitor journeyNew in 1.2

From the entry over the first page to the outcome. Exits stay grey and hatched, every node carries its share of all visits.

Energy flowNew in 1.2

A house with solar roof and battery over a year. Each band keeps the color of the source it starts from, losses are hatched.

Before and afterNew in 1.2

Customers by plan in two quarters. Labels sit outside, each band keeps the color of the plan it came from, cancellations are hatched.

Details

What Sankey chart brings along, read from the registry and the source.

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

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.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • variantoptional
    SankeyVariant

    budgetjourneyenergymigration

  • nodesrequired
    SankeyNode[]
  • linksrequired
    SankeyLink[]
  • formatoptional
    Intl.NumberFormatOptions

    Value format, e.g. { style: 'currency', currency: 'EUR' }

  • suffixoptional
    string

    Unit written after every value, for units Intl does not know, e.g. "kWh"

  • stagesoptional
    string[]

    Names of the stages, shown above the columns

  • heightoptional
    number

    Plot 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
fromEnglish FromGerman Von
toEnglish ToGerman Nach
valueEnglish ValueGerman Wert
shareEnglish ShareGerman Anteil
ofSourceEnglish of {node}German von {node}
ofAllEnglish of allGerman von allem
inflowEnglish InGerman Zufluss
outflowEnglish OutGerman Abfluss
summaryEnglish {n} flows between {s} stages, {total} in the first stage.German {n} Flüsse über {s} Stufen, {total} in der ersten Stufe.
nodeEnglish {label}, {value}, {share} of allGerman {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
components/ui/sankey-chart.tsx
'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.

Often searched

Jump to

Copied to the clipboard