SankeyChart

Where a quantity came from and where it ended up.

Use it for a routing question: which campaigns produced which signups, which budget lines paid for which departments, what the traffic that landed on a page went on to do. The answer has to need a source and a target — a quantity leaves one thing and arrives at another, and the size of each stream is the point.

A ribbon's thickness is its value, on one scale shared by the whole diagram. Nothing else here is a quantity: how far a ribbon travels is just the number of columns between its ends, and the vertical order inside a column is chosen to keep the ribbons from crossing.

Columns come from the links, not from the order of your nodes array. A node that receives from another has to be drawn after it, so the diagram works out the columns itself and align only settles what the flow leaves open.

It needs room. Ribbons carry their value in their thickness, so a diagram of thirty nodes on a phone is thirty hairlines. Past a dozen or so nodes, give it a taller height, or aggregate the long tail before it arrives.

For a total cut into its parts with no routing between them, use TreemapChart. For what survived each step of one process, use FunnelChart.

If the chart you need is not one of these, Plot is the same drawing machinery with nothing decided: you compose the marks over one measured box and one shared scale.

Installation

SankeyChart ships with the library — no separate install.

import { SankeyChart, type SankeyNode, type SankeyLink } from 'panelui-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add sankey-chart

Usage

<SankeyChart nodes={sources} links={flows} height={260}>
  <SankeyChart.Header title="Sessions" value="128,400" />
  <SankeyChart.Links />
  <SankeyChart.Nodes />
  <SankeyChart.Labels />
</SankeyChart>

Composition

<SankeyChart nodes={…} links={…}>
  <SankeyChart.Header />    {/* the strip above the diagram */}
  <SankeyChart.Skeleton />  {/* the plain shape it waits behind */}
  <SankeyChart.Links />     {/* the ribbons */}
  <SankeyChart.Nodes />     {/* the bars they run between */}
  <SankeyChart.Labels />    {/* the names, and the press targets */}
  <SankeyChart.Tooltip />   {/* what the selected node carries */}
</SankeyChart>
  • SankeyChart.Header — The strip above the diagram — what the flow is of, what it totals, and room for a control. The value is not derived, because the formatting is not the chart's to guess: 128400 is a count, a currency or a rate depending on what was routed.
  • SankeyChart.Links — The ribbons. Drawn before the bars, so a bar sits on top of the flows that meet it and keeps a clean edge. Translucent at rest, because ribbons cross and an opaque one hides whichever passes under it.
  • SankeyChart.Nodes — The bars the ribbons run between, drawn solid. A node is the one thing here that is not crossing anything else, and it reads as an edge only if nothing shows through it.
  • SankeyChart.Labels — The names, set beside the bars rather than on them — a bar is ten points thick and no name fits in ten points. Also the press targets: a node worth one percent of the flow is a sliver nobody can hit, so the row it sits in is the target, padded out where the sliver is smaller than one.
  • SankeyChart.Tooltip — What the selected node carries, anchored beside it and clamped to the plot. In and out are shown separately because they are only the same number when nothing was lost — and a node where they differ is the interesting one on the chart.
  • SankeyChart.Skeleton — The waiting state: a few plain bars and the ribbons between them, every one the same size. Varied thicknesses would be an invented routing, and nobody can tell an invented one from a real one until it changes under them.

Examples

The data

Two arrays. Nodes carry an id the links name; links carry a source, a target and a value.

A node's height is worked out for you — it is the larger of what arrives and what leaves. Set value on a node only where those two disagree and the difference matters, such as a step that loses some of what it received to somewhere the links do not describe.

const sources: SankeyNode[] = [
  { id: 'search', label: 'Search' },
  { id: 'social', label: 'Social' },
  { id: 'direct', label: 'Direct' },
  { id: 'signup', label: 'Signed up' },
  { id: 'browse', label: 'Browsed' },
  { id: 'left', label: 'Left' },
];

const flows: SankeyLink[] = [
  { source: 'search', target: 'signup', value: 5200 },
  { source: 'search', target: 'browse', value: 12400 },
  { source: 'social', target: 'browse', value: 8100 },
  { source: 'social', target: 'left', value: 9600 },
  { source: 'direct', target: 'signup', value: 3100 },
  { source: 'direct', target: 'left', value: 2800 },
];

How tall to draw it

height is the one measurement the data cannot supply. The width is the card's, but a diagram of four nodes and one of forty are the same data at two heights — and which is right is a question about the screen rather than about the flow.

It is also the setting that decides whether the small streams are legible. Every ribbon shares one scale, so raising the height thickens all of them together.

<SankeyChart nodes={sources} links={flows} height={320}>
  <SankeyChart.Links />
  <SankeyChart.Nodes />
  <SankeyChart.Labels />
</SankeyChart>

Which column a node goes in

align settles the cases the flow leaves open. justify, the default, pushes every node that feeds nothing into the last column, so the diagram ends on a straight edge of destinations instead of a ragged one. left reads the flow from where things start and lets the endings fall where their depth puts them; right does the opposite.

Reach for left when the columns are stages of a process and a node that stops early genuinely stopped early — with justify it would be drawn against the far edge as though it had gone the distance.

<SankeyChart nodes={sources} links={flows} align="left">
  <SankeyChart.Links />
  <SankeyChart.Nodes />
  <SankeyChart.Labels />
</SankeyChart>

Selecting a node

Pressing a name keeps its ribbons and fades the rest, which is how you follow one stream through a diagram busy enough to need it. The chart tracks the selection itself; pass activeId and onActiveIdChange to drive it from elsewhere, and null to clear it.

Tooltip reads the selected node and needs no wiring of its own.

const [active, setActive] = useState<string | null>(null);

<SankeyChart
  nodes={sources}
  links={flows}
  activeId={active}
  onActiveIdChange={setActive}
>
  <SankeyChart.Links />
  <SankeyChart.Nodes />
  <SankeyChart.Labels />
  <SankeyChart.Tooltip />
</SankeyChart>

Rows that cannot be drawn

Flow data is usually joined together from somewhere nobody owns, and it arrives with rows naming a node that is not there, carrying a zero, or closing a loop. A loop has no left-to-right reading at all.

The chart drops those rows and draws the rest rather than going blank. onDropLinks reports how many went, so the screen can say so — it fires with 0 after a clean render, so one signal both raises the warning and takes it away.

const [dropped, setDropped] = useState(0);

<SankeyChart nodes={sources} links={flows} onDropLinks={setDropped}>
  <SankeyChart.Links />
  <SankeyChart.Nodes />
  <SankeyChart.Labels />
</SankeyChart>

{dropped > 0 ? (
  <Text size="xs" muted>{`${dropped} rows could not be drawn`}</Text>
) : null}

How far the ribbons bend

curve is how far a ribbon's control points reach towards the middle. The default puts both on the centre line, which makes the two halves mirror images and reads as one continuous flow. 0 draws straight diagonals instead — tighter, and easier to follow where many ribbons run between the same pair of columns.

<SankeyChart nodes={sources} links={flows} curve={0}>
  <SankeyChart.Links />
  <SankeyChart.Nodes />
  <SankeyChart.Labels />
</SankeyChart>

Waiting for the data

status="loading" draws a plain shape until the rows arrive, and it dissolves under the real diagram growing across it rather than being swapped out — so the card never has a blank frame in the middle of it.

The placeholder's bars and ribbons are all the same size on purpose. Varied ones would be an invented routing, and a reader cannot tell an invented one from a real one until it changes under them.

<SankeyChart nodes={[]} links={[]} status="loading">
  <SankeyChart.Skeleton />
  <SankeyChart.Links />
  <SankeyChart.Nodes />
  <SankeyChart.Labels />
</SankeyChart>

API Reference

SankeyChart

PropTypeDefaultDescription
classNamestring
nodesSankeyNode[]The stages. Order does not decide position — the links do.
linksSankeyLink[]What travels between them.
heightnumber240How tall the diagram is drawn, in points. The width is the card's, but nothing in a flow says how deep it should be: a diagram of four nodes and one of forty are the same data at two heights, and which of them is right is a question about the screen.
nodeWidthnumber10How thick a node's bar is, in points.
nodePaddingnumber14The gap between two nodes in a column, in points. A maximum rather than a promise. A crowded column gives its spacing up before it gives up the height of its bars, because the bar is the reading.
alignSankeyAlign'justify'Which column a node goes in where the flow leaves a choice.
iterationsnumber6Relaxation rounds spent untangling the ribbons.
curvenumber0.5How far a ribbon bends, 0 for a straight diagonal and 0.5 for an S.
colorstringThe first hue. The rest of the palette follows from the theme's tokens.
animationDurationnumber620Milliseconds for one column to draw itself.
staggerDelaynumber110Milliseconds between one column starting and the next. 0 for all at once.
statusSankeyChartStatus'ready'loading draws a plain placeholder until the data arrives.
activeIdstring | nullSelected node. Leave unset to let the chart track it.
onActiveIdChange(id: string | null) => voidFires with the selected node's id, or null when the selection is cleared.
onDropLinks(count: number) => voidFires with how many link rows could not be drawn — ones naming a node that is not there, carrying nothing, or closing a loop. 0 after a clean render, so a banner can be shown and taken away from the same signal.
PropTypeDefaultDescription
opacitynumber0.4A ribbon's opacity at rest.
activeOpacitynumber0.78A ribbon's opacity when its node is selected.
dimOpacitynumber0.08And when something else is.

SankeyChart.Nodes

PropTypeDefaultDescription
radiusnumberCorner radius on a node's bar, in points.
dimOpacitynumber0.08A bar's opacity when something else is selected.

SankeyChart.Labels

PropTypeDefaultDescription
classNamestring
formatValue(value: number, node: SankeyNode) => stringFormat the figure beside a name. Defaults to a compact number.
showValuebooleanfalseShow the figure under the name.
minHeightnumber6Hide the name on a bar shorter than this, in points. A diagram of forty nodes has bars a few points tall, and forty names at that spacing overlap into a grey band that hides the flow behind it. The names that are dropped are the smallest ones, which is where the tooltip takes over.

SankeyChart.Tooltip

PropTypeDefaultDescription
classNamestring
formatValue(value: number) => stringFormat the figures. Defaults to a compact number.

SankeyChart.Skeleton

PropTypeDefaultDescription
colorstring

SankeyChart.Header

PropTypeDefaultDescription
classNamestring
titlestringSmall line above the value — what the flow is of.
valuestringThe readout. The largest thing on the card, and the first thing read.
captionstringOne muted line under the value — a period, a comparison, a caveat.

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

The entrance runs column by column, so the flow arrives in the order it happens. staggerDelay={0} draws it all at once, and animationDuration sets how long one column takes. Both are ignored where the platform is set to reduce motion — the diagram is simply there.

The imperative handle carries replay, for a control that re-runs the entrance.

Colours come from the theme's five chart tokens, assigned by a node's position in your array and stepped once the palette runs out. A ribbon takes its source node's colour, so a stream is the colour of where it came from; set color on a link to override that, or on a node to fix the node and everything leaving it.

Under a right-to-left layout the flow is mirrored, so it still reads from where it starts.

Public exports

Values: SankeyChart, useSankeyChart

Types: SankeyChartProps, SankeyChartHandle, SankeyChartHeaderProps, SankeyChartLinksProps, SankeyChartNodesProps, SankeyChartLabelsProps, SankeyChartTooltipProps, SankeyChartSkeletonProps, SankeyChartStatus, SankeyNode, SankeyLink, SankeyAlign

On this page