Built with ApexSankey
A Sankey diagram shows quantities flowing between stages, with each band's width proportional to the amount it carries. It answers "where did it all go": traffic through a funnel, energy through a grid, budget through departments, users between plans.
ApexSankey is the one in this family. It renders SVG, has no charting-library dependency, and takes a node list plus an edge list:
npm install apexsankey
<div id="svg-sankey"></div>
<script type="module">
import ApexSankey from 'apexsankey'
const sankey = new ApexSankey(document.getElementById('svg-sankey'), {
width: 800,
height: 400,
})
sankey.render({
nodes: [
{ id: 'visit', title: 'Visited' },
{ id: 'signup', title: 'Signed up' },
{ id: 'trial', title: 'Started trial' },
{ id: 'paid', title: 'Converted' },
{ id: 'churn', title: 'Lapsed' },
],
edges: [
{ source: 'visit', target: 'signup', type: 'web', value: 4200 },
{ source: 'signup', target: 'trial', type: 'web', value: 1800 },
{ source: 'trial', target: 'paid', type: 'web', value: 640 },
{ source: 'trial', target: 'churn', type: 'web', value: 1160 },
],
options: sankey.options,
})
</script>
Four fields per edge: source and target reference node ids, value sets
the band width, and type is the category the band is coloured and grouped by.
What shape does your data need to be in?
This is where most first attempts stall, because the data you have is rarely the data the renderer wants. There are three shapes in practice, and only one of them is what ApexSankey consumes directly.
| What you have | Looks like | What to do |
|---|---|---|
| Edge list | one row per flow: from, to, amount | Already the right shape. Map the column names to source, target, value and derive nodes from the distinct ids. |
| Pivoted wide | one row per subject, one column per stage: user, plan_2024, plan_2025 | Use buildAlluvialData. It converts stage columns into the node and edge lists for you. |
| Adjacency matrix | a square grid, sources down the side, targets across the top | Convert it yourself. There is no builder, but it is a nested loop (below). |
Pivoted wide: use the builder
If each row is a subject and each column is a stage it passed through, that is
an alluvial diagram, and buildAlluvialData does the reshaping:
import ApexSankey, { buildAlluvialData } from 'apexsankey'
const data = buildAlluvialData({
dimensions: ['2024', '2025'], // ordered stages, left to right
records: [
{ values: { 2024: 'Free', 2025: 'Pro' } },
{ values: { 2024: 'Free', 2025: 'Free' } },
{ values: { 2024: 'Pro', 2025: 'Pro' } },
{ values: { 2024: 'Pro' } }, // no 2025 key: contributes no flow
],
})
sankey.render({ ...data, options: sankey.options })
Import buildAlluvialData as a named export, as above. It is also reachable at
runtime as ApexSankey.buildAlluvialData, which is what a script tag has to
use since it has no import to write, but that form is absent from the bundled
type declarations, so TypeScript rejects it.
Three behaviours worth knowing before you rely on it, all of them measured
against apexsankey 1.12.0:
- A category that appears at two stages becomes two nodes, not one. "Free"
in 2024 and "Free" in 2025 are separate nodes with generated ids
(
alluvial-0,alluvial-2) and the sametitle. That is correct for an alluvial diagram, where each column is its own axis, and it surprises people who expect one node per category. - A record missing a stage key contributes no flow across that gap. The
fourth record above has no
2025value, so it produces no edge. It is dropped silently, which is convenient for sparse data and quietly wrong if the missing keys were meant to be a "no change" category. Fill them in explicitly if they matter. valuedefaults to 1 per record, so an omittedvaluecounts subjects rather than summing an amount, and records sharing an adjacency are summed. The generated edgetypeis the source category, which is what colours each band by where it came from.
Adjacency matrix: convert it
No builder for this one, and it does not need one:
const labels = ['Raw', 'Refined', 'Export', 'Domestic']
const matrix = [
[0, 120, 0, 0], // Raw -> Refined 120
[0, 0, 70, 50], // Refined -> Export 70, Domestic 50
[0, 0, 0, 0],
[0, 0, 0, 0],
]
const nodes = labels.map((title, i) => ({ id: String(i), title }))
const edges = []
for (let row = 0; row < matrix.length; row++) {
for (let col = 0; col < matrix[row].length; col++) {
const value = matrix[row][col]
if (value > 0) {
edges.push({ source: String(row), target: String(col), type: labels[row], value })
}
}
}
sankey.render({ nodes, edges, options: sankey.options })
The if (value > 0) guard is doing real work. Skip it and every empty cell
becomes a zero-value flow, which is the first item in the next section.
Why is my Sankey diagram blank?
Measured against apexsankey 1.12.0, in order of how often it is the answer.
| Symptom | Cause |
|---|---|
| Nothing at all | edges is empty. Nodes alone draw nothing, not even their rectangles. |
| Rectangles missing, no labels | Every flow has value: 0. The nodes are in the DOM with height="0". |
A thrown TypeError | nodes was omitted. It is required; the renderer does not infer it from the edges. |
The good news is that most of what people expect to break does not:
- Cycles render. A flow that loops back (
CollecttoRecycletoRaw, whereRawstarted the chain) draws without any configuration and without a flag to set. There is a shipped circular Sankey demo. - Self-loops render. An edge whose
sourceandtargetare the same node draws its own band. - String values are coerced.
value: '5'works, which matters because a CSV or JSON column arrives as text more often than not. You still want real numbers if you plan to sum them yourself. - An edge may reference a node you never declared. ApexSankey creates it
rather than failing, using the id as the label. Handy for quick sketches, and
worth knowing because it means a typo in a
targetadds a stray node instead of raising an error.
When is a Sankey the wrong chart?
A Sankey earns its complexity when the magnitude of the flow is the point. If it is not, something simpler reads better.
| What you want to show | Reach for |
|---|---|
| Quantities splitting and merging across stages | A Sankey. This is the case it exists for. |
| Drop-off through a fixed linear sequence, no branching | A funnel chart. A Sankey with one path per stage is a funnel with extra steps. |
| Which categories are biggest, no flow between them | A bar chart. |
| Parts of a whole at one moment | A treemap, or a bar chart if the labels matter. |
| A hierarchy, where each node has exactly one parent | A tree or org chart. Sankey nodes can have many inputs; if yours cannot, you have a tree. |
| Relationships with no direction or quantity | A network or chord diagram. ApexSankey ships a chord demo for the circular case. |
| More than roughly 30 nodes | Aggregate first. Beyond that the bands get thinner than the gaps between them and nobody can trace a path. |
The hierarchy row is the one that catches people most. If every node has a single parent, a Sankey draws it, but the layout spends its width proving something a tree shows at a glance.
What ApexSankey ships
ApexSankey is a commercial library, included from the Pro plan upward. It is not part of the Community tier, so unlike ApexCharts.js it is not covered by the under-$2M waiver. Nothing here is open source: source published on GitHub is not the same thing as an open licence. Everything below renders in full without a licence key, watermarked, so you can build the real diagram with your own data before deciding. The pricing page has the plan matrix.
| Included | |
|---|---|
| Horizontal and vertical orientation | Yes |
| Circular flows and chord layout | Yes |
Alluvial builder (buildAlluvialData) | Yes |
| Node dragging, path highlighting | Yes |
| Drill-down and group collapsing | Yes |
| Before/after comparison view | Yes |
| Time playback across frames | Yes |
| Particle flow animation | Yes |
| Theming, CSS custom properties, custom fonts | Yes |
| Localization and RTL | Yes |
| WCAG 2.1 AA accessibility support | Yes |
| SVG export | Yes |
Using a Sankey with the rest of a dashboard
A flow diagram is usually one panel among several, and clicking a band to filter the others is the interaction people ask for first. The cross-product wiring is its own recipe:
Drill down from a chart into a gridSee the pieces running
Reference documentation
Frequently Asked Questions
What is a Sankey diagram used for?
Showing quantities that flow between stages, where each band's width is proportional to the amount it carries. Common cases are conversion funnels that branch, energy or material flow, budget allocation, and users moving between plans or states over time.
Why is my Sankey diagram blank?
Almost always because `edges` is empty: nodes on their own draw nothing in ApexSankey, not even their rectangles. The second most common cause is every flow having `value: 0`, which renders the nodes with a height of zero and no labels. Omitting `nodes` entirely is different again: it throws a TypeError rather than rendering empty.
Can a Sankey diagram show circular flows?
In ApexSankey, yes, with no configuration and no flag to set. A flow that loops back on itself renders, and so does a self-loop whose source and target are the same node. Verified on apexsankey 1.12.0.
How do I convert wide data into a Sankey diagram?
If each row is a subject and each column is a stage it passed through, use the `buildAlluvialData` named export: pass an ordered `dimensions` array and a `records` array, and it returns the `{ nodes, edges }` pair that `render()` takes. A category appearing at two stages becomes two nodes, and a record missing a stage key contributes no flow across that gap.
When should I use a funnel chart instead of a Sankey?
When the sequence is linear and nothing branches or merges. A Sankey with exactly one path per stage is a funnel chart with more layout machinery. Reach for the Sankey the moment flows split, rejoin, or come from more than one source.
Related
Start with ApexSankey
Zero dependencies, and free for organizations under $2M in annual revenue.