Skip to content
KONIGI Patterns

Dashboards / Charts and data displays / Sankey and path

‹ 15 of 22 ›

Sankey and path

People need to see how quantities split and move through different routes.

Updated September 10, 2026

Problem

A fixed sequence does not show the different routes people or resources take through a process.

Solution

Draw stages as nodes and movement between them as bands whose widths represent quantity. Label the measure and provide exact values on interaction.

Include exits or losses when flows do not continue to another shown stage. Make clear what is counted so incoming and outgoing quantities can be understood.

Reduce crossing lines and keep node positions stable between updates. Limit the view to a readable set of routes, with a way to investigate grouped or omitted paths.

Use when

The route branches and people need to understand how volume moves between a manageable set of stages.

Don’t use when

The sequence is fixed, where a funnel is clearer and cheaper. Or when the paths are long-tailed: real user journeys have thousands of distinct routes, and a diagram of the top eight plus “other” is either a lie by omission or an unreadable hairball.

Trade-offs

Crossing bands and many routes quickly become difficult to read. The diagram needs space and is weak for exact comparisons. Grouping small paths can hide the unusual route people are trying to find.

Checklist

  • Do the flows conserve, and if not, is there an explicit exit node?
  • How many distinct paths exist, and what fraction is shown before “other”?
  • Does “other” carry a count, so the viewer can size what is hidden?
  • Are crossings minimised, and is node order stable between refreshes?
  • Can the viewer get an exact number for a band?
  • Is the direction of flow unambiguous?
  • Does colour encode anything, or is it decoration?
  • Is there a minimum band width below which a path is dropped, and is that stated?
  • Would a funnel or a table answer this question with less machinery?
  • Does the diagram still work at the panel size it will actually render at?

Compare

Amplitude and Mixpanel offer path analysis where the Sankey is generated from event streams and each node is clickable into the underlying users, which turns a picture into a starting point. Google Analytics has shipped several versions of a flow report over the years and each has struggled with the same long-tail problem, which is a useful demonstration that this is inherent rather than an implementation failure. Grafana has no core Sankey panel and relies on community plugins, so it appears on dashboards rarely and usually as a one-off. Datadog applies the encoding to service dependencies and request routing rather than to people, where conservation actually holds better and the node set is bounded.

Funnel fits a fixed sequence. Service map emphasizes connections rather than flow volume. Stacked composition shows shares at a point in time.

Drill-down opens a route or node. Cohort grid follows groups over elapsed time.

Sankey and path anatomy Flow from one entry point through two columns of states, with link width carrying volume. An explicit exit node absorbs everyone who left, so the widths still add up, and one band loops back to search, which is the route nobody designed. Width is volume, and it has to conserve landing browse help left cart left 1 2 3 1 LINK WIDTH The encoding comes from engineering, where what enters a node leaves it. That conservation is what makes width readable as quantity. 2 AN EXIT NODE Which product analytics violates constantly, because people leave. With nowhere for them to go, the widths quietly stop adding up. 3 THE ROUTE NOBODY DESIGNED Where a funnel says how many survived each step, this says which way they went, and that matters exactly when the route varies. Everyone who detoured through help reached the cart, and the funnel had no column for them. Node ordering within a column decides how many links cross, and crossings are what turn the diagram from legible into decorative.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Flow where the band width is the volume. Worth it when the branching is the question and expensive when it is not, because a Sankey with more than a few nodes becomes a plate of spaghetti nobody reads twice.

Tokens
--foreground--card--muted-foreground--border--status-warn--chart-1
landing → browse: 2,260landing → help: 700browse → cart: 1,365help → cart: 700landing → left: 1,040browse → left: 895landing: 4,000landingbrowse: 2,260browsehelp: 700helpcart: 2,065cartleft: 1,040leftleft: 895left

Sankey.tsxLays out columns from data, scales band width to volume, and sends whatever a node fails to pass on to a computed exit node so the widths conserve.

/**
 * Width is volume, and it has to conserve. What enters a node leaves it, or
 * the widths stop meaning anything, so any node whose outflow is short of its
 * inflow gets the remainder sent to an exit node in the next column. The
 * exit is computed rather than optional: product flows leak people, and the
 * diagram has to say where.
 */
export type Node = {
  id: string;
  label: string;
  column: number;
  /** What entered a node that has no inbound links, so its leak can be computed too. */
  value?: number;
};
export type Link = { from: string; to: string; value: number };

const NODE_W = 14, GAP = 14, PAD = 30;

export function Sankey({ nodes, links, exit = "left", highlight, width = 440, height = 250, onSelect }: {
  /** In display order within each column. Keep the order stable between renders so the diagram can be learned. */
  nodes: Node[];
  links: Link[];
  /** Label for the computed exit node. */
  exit?: string;
  /** A node whose bands are drawn as the route worth looking at. */
  highlight?: string;
  width?: number;
  height?: number;
  onSelect?: (link: Link) => void;
}) {
  const inflow = (n: Node) => links.filter((l) => l.to === n.id).reduce((s, l) => s + l.value, 0) || n.value || 0;
  const outflow = (id: string) => links.filter((l) => l.from === id).reduce((n, l) => n + l.value, 0);

  // Conservation: send whatever leaks out of a node to an exit in the next column.
  const cols = Math.max(...nodes.map((n) => n.column)) + 1;
  const all: Node[] = [...nodes], flows: Link[] = [...links];
  for (const n of nodes) {
    const lost = inflow(n) - outflow(n.id);
    if (n.column === cols - 1 || lost <= 0) continue;
    const id = `${exit}:${n.column + 1}`;
    if (!all.some((x) => x.id === id)) all.push({ id, label: exit, column: n.column + 1 });
    flows.push({ from: n.id, to: id, value: lost });
  }
  const size = (id: string) => Math.max(
    flows.filter((l) => l.to === id).reduce((n, l) => n + l.value, 0),
    flows.filter((l) => l.from === id).reduce((n, l) => n + l.value, 0),
  );
  const r = (v: number) => +v.toFixed(1);

  // Stack each column, scaled so the tallest column fills the height.
  const byCol = Array.from({ length: cols }, (_, c) => all.filter((n) => n.column === c));
  const scale = Math.min(...byCol.map((col) => (height - 2 * PAD - GAP * (col.length - 1)) / col.reduce((n, x) => n + size(x.id), 0)));
  const x = (c: number) => PAD + 40 + (c * (width - 2 * PAD - 60 - NODE_W)) / (cols - 1);
  const pos: Record<string, { x: number; y: number; h: number; out: number; in: number }> = {};
  byCol.forEach((col, c) => {
    let y = PAD;
    for (const n of col) { const h = size(n.id) * scale; pos[n.id] = { x: x(c), y, h, out: y, in: y }; y += h + GAP; }
  });

  const isExit = (id: string) => id.startsWith(`${exit}:`);
  const band = (l: Link) => {
    const a = pos[l.from], b = pos[l.to], h = l.value * scale;
    const y0 = a.out, y1 = b.in;
    a.out += h; b.in += h;
    const x0 = a.x + NODE_W, x1 = b.x, cx = (x0 + x1) / 2;
    return `M${x0} ${r(y0)} C${cx} ${r(y0)} ${cx} ${r(y1)} ${x1} ${r(y1)} L${x1} ${r(y1 + h)} C${cx} ${r(y1 + h)} ${cx} ${r(y0 + h)} ${x0} ${r(y0 + h)} Z`;
  };

  return (
    <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-label="flow between states, band width is volume" className="block max-w-full">
      {flows.map((l) => (
        <path key={`${l.from}>${l.to}`} d={band(l)} onClick={onSelect && (() => onSelect(l))}
          className={isExit(l.to) ? "fill-muted-foreground/30" : l.from === highlight || l.to === highlight ? "fill-status-warn/50" : "fill-chart-1/40"}>
          <title>{`${all.find((n) => n.id === l.from)!.label} → ${all.find((n) => n.id === l.to)!.label}: ${l.value.toLocaleString("en-GB")}`}</title>
        </path>
      ))}
      {all.map((n) => {
        const p = pos[n.id], right = isExit(n.id), first = n.column === 0;
        const [bar, label] = right ? ["fill-muted-foreground", "fill-muted-foreground"]
          : n.id === highlight ? ["fill-status-warn", "fill-status-warn"]
          : ["fill-chart-1", "fill-foreground"];
        return (
          <g key={n.id}>
            <rect x={p.x} y={r(p.y)} width={NODE_W} height={r(p.h)} className={bar}><title>{`${n.label}: ${size(n.id).toLocaleString("en-GB")}`}</title></rect>
            <text
              x={right ? p.x + NODE_W + 8 : first ? p.x - 8 : p.x}
              y={r(right || first ? p.y + p.h / 2 + 3 : p.y - 8)}
              textAnchor={first ? "end" : "start"}
              className={`text-[9px] ${label}`}
            >{n.label}</text>
          </g>
        );
      })}
    </svg>
  );
}

demo.tsxHow it is called: landing to browse or help to cart, with the leaks computed and the detour through help highlighted.

import { Sankey } from "./Sankey";

/**
 * Four thousand people landed on the pricing page. The links only say where
 * people went; the exits are computed from what each node failed to pass on.
 * Help is highlighted because everyone who detoured through it reached the
 * cart, and the funnel had no column for them.
 */
export default function Demo() {
  return (
    <div className="w-fit rounded-lg border bg-card p-4">
      <Sankey
        nodes={[
          { id: "landing", label: "landing", column: 0, value: 4000 },
          { id: "browse", label: "browse", column: 1 },
          { id: "help", label: "help", column: 1 },
          { id: "cart", label: "cart", column: 2 },
        ]}
        links={[
          { from: "landing", to: "browse", value: 2260 },
          { from: "landing", to: "help", value: 700 },
          { from: "browse", to: "cart", value: 1365 },
          { from: "help", to: "cart", value: 700 },
        ]}
        highlight="help"
        onSelect={(l) => console.log(l)}
      />
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

Screenshots are grouped by product and include the capture date. Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.

Tableau Public

Published workbooks show how individual authors arrange charts and interactions in Tableau.

Energy flows in the Regional scenario September 11, 2026 Tableau Public embed view; workbook published by Chia Yu Lin medium · light · desktop-web
The diagram follows energy from supply to demand using band widths for quantity. It includes conversion losses as a separate destination, so the difference between the stated supply and demand has an explanation. Carrier colors stay consistent across the columns, helping readers follow a flow. Layout controls are exposed beside the chart, including a highly precise spacing value that adds little to the data reading.