Sankey Chart
Token-driven flow chart built on Recharts v3 — weighted links between nodes in columns, for where a total comes from and where it goes.
pnpm add @tessinaui/uiWhen to use
- To show where a total comes from and where it goes. Income split into spending, revenue into costs and profit, energy from its sources to its uses. Each band is as wide as the amount it carries.
- To show a journey with its drop-offs. A checkout, a sign-up or a product tour: each step is a column, and the people who left at a step are a band of their own (Journey and drop-off).
- To show how groups mix on the way through. Traffic from four channels landing on three pages, then ending in a sign-up or not.
When not to use
- For one step of shares. A total split once, with nothing before or after it, is a PieChart or a BarChart.
- For a trend over time. Use LineChart or AreaChart.
- For a flow that loops back. A Sankey reads in one direction. A link that returns to an earlier node cannot be drawn, and the chart shows its error state instead (States).
- For more than about twenty nodes. Bands get thin, labels drop to the tooltip, and the keyboard walk gets long. Fold the small ones into "Other" first (Group the tail).
- When the exact values matter more than the shape. Put the flows in a Table and keep the chart as the summary.
Installation
pnpm add @tessinaui/ui rechartsUsage
import { SankeyChart } from "@tessinaui/ui";
import type { SankeyChartData } from "@tessinaui/ui";
// Nodes by id; links name their ends by that id.
const data: SankeyChartData = {
nodes: [
{ id: "salary", label: "Salary" },
{ id: "income", label: "Income" },
{ id: "rent", label: "Rent" },
{ id: "saved", label: "Saved" },
],
links: [
{ source: "salary", target: "income", value: 4000 },
{ source: "income", target: "rent", value: 1600 },
{ source: "income", target: "saved", value: 2400 },
],
};
<SankeyChart data={data} label="Six tenths of the salary is saved" />Examples
Default
One hue for every node and a band for every flow. Point at a node to see its flows in and out, or at a band for its share of where it came from. The rest step back while you do.
Node colours
Colour a node through config. Give colour to the nodes a reader has to tell
apart, here the four channels, and leave the rest in the chart's one hue. There
are six chart colours, and a flow often has more nodes than that.
Link colour
linkColor paints each band with its source node's colour, its target's, a
gradient from one to the other, or neutral, leaving every colour to the
nodes. The default is source.
Alignment and order
align="justify", the default, puts every node with nothing going out in the
last column. align="left" keeps each node one column past what feeds it, so a
flow that ends early ends where it stops. sort orders each column by value,
largest first; sort={false} keeps the order of your data.
Nodes
nodeWidth and nodePadding set a node's thickness and the space between
nodes, linkCurvature how far a band bends, and rounded the node's corners.
Labels
labels prints a node's name, its value under the name (the default), its
percent of the total, both, or none. The total is everything that flows out
of the first column. A label that does not fit beside its node is left to the
tooltip, so labels never print over each other.
Icons
An icon in a node's config entry draws before its name, and in the legend.
Journey and drop-off
columns puts a heading over each column, for the steps of a journey. The
people who leave at a step are a node of their own. Give those nodes a neutral
tone, not an error colour: leaving is part of a funnel, not a fault.
Path highlight
Pointing at or focusing a node dims everything more than one hop away. A
journey is read by path, so highlight="path" keeps every flow through the
node at full strength instead, back to where it came from and on to where it
ended. Point at a channel to follow it to payment, or at "Paid" to see every
route that got there. A band keeps what fed its source and what its target
went on to.
Choosing a node
With selectionMode="single" a click, a tap, or Enter or Space on a focused
node chooses it, and choosing it again clears it. The chosen node wears a ring;
the nodes off its flows step back to a wash inside an outline, and their bands
fade, the same set a hover lights (a path under highlight="path"). A band is
not a choice: clicking one chooses nothing. Here a card beside the chart lists
the chosen node's flows in and out, and its × clears the choice.
Tooltip and formatting
valueFormatter spells every number the chart prints, in the labels and the
tooltip. A node's tooltip lists its total and every flow in and out of it. A
band's tooltip gives its value and its share of its source.
Legend
showLegend lists the nodes that carry a colour, in config order. It is off
by default because every node is already named beside it; turn it on when the
colours group nodes, as the three admission routes do here.
Group the tail
Fold the small flows into one "Other" node before passing the data. Nine categories would be nine thin bands and nine labels; four with "Other" read at a glance.
In a card
The heading, the totals and any controls belong to the card. The chart draws the flow.
Sizes
size sets the aspect ratio: sm 3:1, md 2:1, lg 16:9. Each has a
minimum height, so the columns keep room to separate in a narrow container.
Narrow containers
Below 480px the chart prints names only, inside the columns, and the figures move to the tooltip. A flow with more columns than fit at 112px each keeps that width and scrolls sideways. Moving focus to a node scrolls it into view.
Accessible name and data table
Name the chart with its insight, put the numbers in description, and offer
the flows as a table when someone may need the exact values.
On a coloured plate
Inside a coloured Surface a node with no colour takes the plate's ink, the
bands a wash of it, and the labels its text tiers. No prop is needed.
Right-to-left
The flow runs left to right in every direction, as the reading of time does.
With dir="rtl" the words the chart writes are Arabic, and the tooltip and the
names it reads out follow the page.
States
loading draws a skeleton in the chart's shape, error shows your message,
and a flow with nothing to draw shows emptyState. A flow that loops back on
itself shows the error state too, and a development warning names the link that
closes the loop.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
data | { nodes: { id, label? }[]; links: { source, target, value }[] } | — | The flow. Links name their ends by node id. A link of zero or less is not drawn, and a node no link touches is left out. |
config | ChartConfig | — | Node metadata keyed by node id: label, color or theme, icon. |
linkColor | "source" | "target" | "gradient" | "neutral" | "source" | What a band is painted with. |
align | "justify" | "left" | "justify" | Where a node with nothing going out sits. |
sort | boolean | true | Order each column by value. false keeps data order. |
nodeWidth | number | 12 | Node thickness, in px. |
nodePadding | number | 16 | Space between nodes in a column, in px. |
linkCurvature | number | 0.5 | How far a band bends, 0 to 1. |
labels | "name" | "value" | "percent" | "value-percent" | "none" | "value" | What prints beside each node. |
columns | string[] | — | A heading over each column, in order. |
highlight | "adjacent" | "path" | "adjacent" | What stays at full strength while a node or band is active: one hop, or every path through it. |
selectionMode | "none" | "single" | "none" | "single" lets a reader choose one node by click, tap, Enter or Space; choosing it again clears it. Links do not select. |
selected | string | null | — | The chosen node's id, controlled. null chooses nothing. |
defaultSelected | string | null | null | The node chosen at first render, uncontrolled. |
onSelectedChange | (id, { datum, index, source }) => void | — | Called on every choice and clear; datum is the node from data.nodes, null on a clear. |
active | string | null | — | The node lit as if pointed at, controlled — to light a node from a list beside the chart. |
onActiveChange | (id: string | null) => void | — | The node under the chart's own pointer or focus; a band reports null. |
valueFormatter | (value: number) => string | Intl.NumberFormat | Formats every value the chart prints. |
rounded | "none" | "sm" | "md" | "lg" | "full" | "md" | Node corners. |
size | "sm" | "md" | "lg" | "md" | Aspect ratio. |
showLegend | boolean | false | A key for the nodes that carry a colour. |
showTooltip | boolean | true | Show the tooltip on point and focus. |
strings | Partial<SankeyChartStrings> | follows dir | Every word the chart writes, per entry. |
children | ReactNode | — | Extra Recharts children, drawn inside the chart's svg. |
dir | "ltr" | "rtl" | — | Picks the chart's words. The flow runs left to right either way. |
label | string | — | Accessible name; renders the chart as a role="figure". |
description | string | — | Visually hidden summary for assistive tech. |
loading | boolean | false | Render the skeleton. |
error | ReactNode | — | Render an error message instead of the chart. |
emptyState | ReactNode | "No data to display" | Shown when no link can be drawn. |
Colour
A node with no colour in config takes --chart-1, so one hue carries the
whole flow and colour stays free for the nodes that need it. There are six
chart colours; when more nodes need telling apart, group them, fold the small
ones into "Other", or let the labels name them.
Colour is never the only thing that names a node. Every node is labelled beside it or, where the label does not fit, in its tooltip and its accessible name.
Do not colour a node with a success or error token. A chart colour is a
category, not a verdict. A drop-off node takes a neutral tone such as
hsl(var(--muted-foreground)).
Accessibility
- Give every chart a
label. State the insight: "Housing and savings take more than half the month", not "Sankey chart of the budget". - Put the numbers in
description. A static read should carry the message without anyone stepping through the nodes. - Keyboard. Tab moves focus to the first node and draws the focus ring
around it. The Up and Down arrow keys move within a column; Left and Right
move to the nearest node in the next column; Home and End go to the first and
the last node. The chart is one stop in the tab order. Each node is read as its
name, value, share of the total and how many flows go in and out, and its
tooltip lists every one of those flows, so each band's value is reachable from
the keyboard. Escape hides the tooltip and keeps focus. With
selectionMode="single", Enter or Space chooses the focused node or clears it; Escape never clears a choice. - Screen readers. The nodes are a list, or a list box of options that say whether they are selected when the chart selects; a choice and a clear are announced once. The printed labels are hidden from assistive tech, because each node's name already says them in full, where the printed line may be cut to fit.
- Pointer. Every node and every band has a target wider than its mark: 24px with a mouse, 44px under a finger.
- Motion. Nothing animates in. The stepping back of the other nodes and
bands is a fade, and under
prefers-reduced-motionit is instant. A choice changes state at once: its ring and the step-back move nothing. - Not yet verified with VoiceOver, NVDA or JAWS, which is why the component is in beta.
Composition
children are drawn inside the chart's svg, so Recharts' own parts still
compose. The chart draws its own tooltip and legend; do not add Recharts'
Tooltip or Legend beside them.
Boundaries
- No loops. A flow that returns to an earlier node is reported, not drawn.
- No vertical layout. The flow runs left to right. On a phone it prints names only and, past its column floor, scrolls.
- No click or selection yet. A node shows its flows on hover and focus; it is not a control.
- No highlight of a whole path. Pointing at a node keeps its own flows and their ends at full strength, one step each way.
Theming
Node colours come from config, as --chart-1 to --chart-6 or any colour
you pass; both themes are handled by the tokens. Labels, column headings, the
legend and the tooltip use the page's text, border and surface tokens, and
follow a coloured Surface without a prop.