D3 Sankey Chart
When a stacked bar would lie about the flow, I draw a sankey. Here's one in D3 with the plugin the core build leaves out.
A D3 sankey chart is how I show “this much went there” when a stacked bar would be misleading. Energy mixes, budget allocations, onboarding funnels — the width of each ribbon is the quantity, and if you label the nodes clearly, people understand it without needing a legend.
One catch: sankey is not part of the core D3 build. You need to add d3-sankey separately. Same SVG approach as the D3.js page, just a different layout function. If your flow is geographic — money moving between countries, that kind of thing — the map example is usually clearer.
When a sankey helps
Use it when conservation matters: the sum of the inflows should match the outflows, give or take a “loss” node. Don't use it for a time series. That's a line, and I already have D3 graph examples for that.
A D3 sankey chart
Dummy energy: solar, wind, and gas into a grid, then homes, industry, and loss. Hover a ribbon for the value. The numbers are made up. The layout is the real API.
Preview
The code
Clone nodes and links before you pass them in. The layout mutates the objects. If you skip the clone, the second render is a mess.
Code
const data = {
nodes: [{ name: "Solar" }, { name: "Grid" }, { name: "Homes" }],
links: [
{ source: "Solar", target: "Grid", value: 24 },
{ source: "Grid", target: "Homes", value: 24 },
],
};
const layout = d3.sankey()
.nodeId((d) => d.name)
.nodeWidth(14)
.nodePadding(20)
.extent([[4, 12], [636, 328]]);
const graph = layout({
nodes: data.nodes.map((d) => ({ ...d })),
links: data.links.map((d) => ({ ...d })),
});
svg.selectAll("path")
.data(graph.links)
.join("path")
.attr("d", d3.sankeyLinkHorizontal())
.attr("fill", "none")
.attr("stroke", "#e8b84a")
.attr("stroke-opacity", 0.35)
.attr("stroke-width", (d) => Math.max(1, d.width));
Gotchas
-
nodeId. If you're identifying nodes by name (which
you almost certainly are), you have to tell the layout explicitly:
.nodeId(d => d.name). The default accessor is the array index, so a typo in asourceortargetfield fails quietly rather than throwing an error. - Cycles. d3-sankey expects a directed acyclic graph. If your data has a loop — A flows to B, B flows to A — the layout will produce garbage. Either break the cycle or model it differently.
-
Tiny values. Links with very small values can produce
a
stroke-widthbelow 1px, which renders as invisible. I clamp withMath.max(1, d.width).
And the most common mistake of all: loading d3-sankey before D3, or not
loading it at all. If d3.sankey is undefined, you'll get a
blank box with no useful error. Load the plugin after D3 and that fixes it.
Questions I get
What is a D3 sankey chart?
A flow diagram where the width of each ribbon represents a quantity. Nodes are steps, stages, or buckets. Links show amounts moving between them. It's particularly useful for things like energy flows, budget allocation, or conversion funnels — anywhere conservation matters and you want to show where things go. One thing to know: d3-sankey is not part of the core D3 build, so you have to load it separately.
Why is d3.sankey undefined?
Because d3-sankey isn't included in the main D3 bundle. You need to load it separately, after D3. The UMD build attaches sankey and sankeyLinkHorizontal directly onto the d3 global, so once it's loaded you can call d3.sankey() like any other D3 method.
Can a sankey have cycles?
Not with d3-sankey. The layout algorithm assumes a directed acyclic graph — every link flows left to right with no loops. If your data has a cycle (A flows to B, B flows back to A), the layout will break in confusing ways. You'll need to either drop the cycle, model it differently, or use a different diagram type altogether.