Sankey
Expandable flow visualisation.
Live demo
01Click Coal or Natural Gas to expand. Solar has no children. Database: connected.
Recipes
- Energy mix dashboard. Render the full
FALLBACK_SANKEY_DATAatheight={600}for a desktop dashboard. Themin-width: 800pxbaked into the component means mobile users get a horizontal scroll instead of squashed labels. - Budget & OKR rollups. Use the compact 10-node shape β one source, two expandable categories, a static third β to show how a fixed pot of money or capacity is being spent. Keep
heightbelow 500px so it fits inside a card. - Pre-expanded snapshots. When you need a static screenshot for a report, instantiate
createSankeyData()yourself, call.expand(node)on the categories you want open, and pass the resulting.nodes/.linksstraight into the component. The "Before vs After" tab above does exactly this.
Implementation
02<script>
import ExpandableSankey from '$lib/components/ExpandableSankey.svelte';
</script>
β
<ExpandableSankey
nodes={data.sankeyData.nodes}
links={data.sankeyData.links}
height={600}
/>ExpandableSankey wraps Unovis to render a Sankey diagram with click-to-expand nodes. The createSankeyData() helper tracks expanded state and recursively collapses children. Aggregate links collapse to summary flows when a node is closed; detailed child links appear when it is expanded. Unovis owns the SVG layer and tooltips. Every variant on this page is the same component β only the data and pre-expansion state differs.
Logic explainer
03What Does It Do? (Plain English)
A flow diagram with drill-down. The component (named ExpandableSankey in the source β see Naming note below) renders nodes as vertical bars and connects them with proportionally-thick coloured ribbons that show how much "flows" from each source to each destination. Click an expandable node and its children fan out, replacing the aggregated link with detailed sub-flows. Click again to collapse β and any descendants you'd opened collapse with it, recursively.
The visual idiom comes from energy and budget reporting: "where does our power come from, and where does it go". It's the right shape whenever you have directed, conserved flow through stages β power generation β distribution β consumption, traffic sources β landing pages β conversions, ingredients β dishes β calories, money β departments β projects.
The expand/collapse interaction is what makes this version useful: a static Sankey of 200 leaves is a hairball, but a Sankey that starts with 6 collapsed top-level groups and lets the reader drill in is legible.
Naming note
The component file is ExpandableSankey.svelte. The route is /sankey. The library catalog also calls it "Sankey". This is a deliberate naming asymmetry: the file name is technical (it describes the behaviour), the public-facing name is short (it describes the thing). When importing, you'll always write import ExpandableSankey from '$lib/components/ExpandableSankey.svelte'.
How It Works (Pseudo-Code)
state:
data = createSankeyData(nodes, links) // visibility-aware data manager
// sankeyData.nodes / .links are filtered views of the input
derive sankey rendering (delegated to Unovis):
Unovis runs its own layout: assigns x positions by topological depth, y by
cumulative flow, computes link curves between node ports
events:
on node click(n):
if !n.expandable: ignore
else:
if n.expanded: sankeyData.collapse(n) // also collapses every descendant
else: sankeyData.expand(n)
data = sankeyData // reassign to trigger Svelte 5 reactivity
callbacks fed to <VisSankey>:
linkColor(d): source node's colour or fallback grey
nodeCursor(d): 'pointer' if expandable, else null
events: { [Sankey.selectors.node]: { click: toggleGroup } }
createSankeyData(allNodes, allLinks) returns:
expand(n) β mark n.expanded = true; subsequent .nodes/.links views show n's children
collapse(n) β mark n.expanded = false; recursively collapse descendants
visibility rules:
nodes: top-level (no parent) always visible; children visible iff parent.expanded
links: source AND target both visible AND
- if parent has children: aggregate link visible iff parent NOT expanded
- child link visible iff parent IS expandedThe "magic" is in the visibility rules β there's no animation between states, no morph from aggregate to detail. Both link sets exist in the input data; visibility flips between them based on the parent's expanded state, and Unovis re-layouts to fill the new graph.
Core Concept: Aggregate vs Detail Links + The Reactivity Trick
Two ideas you have to internalise to use this component.
Aggregate vs detail links
When Coal is collapsed, you want a single thick ribbon from Power Grid β Coal β Residential summarising "100 MW total". When Coal is expanded, you want three separate ribbons: Coal β Plant A β Residential (40 MW), Coal β Plant B β Residential (35 MW), Coal β Plant C β Residential (25 MW).
You provide both in the input. The visibility rule decides which renders:
For a link L = (source β target, value):
if source is expandable AND has children:
show L iff source.expanded === false // aggregate link
if source has a parent that is expandable:
show L iff source.parent.expanded === true // detail linkThe values must reconcile: the aggregate value must equal the sum of the detail values it replaces. This isn't enforced in code β it's your job. Mismatches don't crash; they produce a Sankey that looks slightly different sizes when you expand and collapse, which users will notice. Keep them aligned.
The Svelte 5 reactivity trick
createSankeyData returns a stateful object whose expand/collapse methods mutate internal flags. Mutations to object properties don't trigger Svelte 5's $state reactivity β the runtime tracks reference identity, not deep changes.
The fix is the assignment-to-self pattern:
let data = $state(sankeyData);
function toggleGroup(n) {
if (n.expanded) sankeyData.collapse(n);
else sankeyData.expand(n);
data = sankeyData; // β this looks redundant but isn't
}data = sankeyData creates a new dependency-tracking entry even though the reference is identical. The <VisSingleContainer {data}> consumer sees a "change" and re-runs the Sankey layout against the now-different data.nodes / data.links views.
If you forget that line, the visibility flags update internally but Unovis never re-renders. This is the most common bug when adapting this component.
Performance
The component delegates layout to Unovis (a D3-based viz library). Layout cost is linear in the number of visible nodes and links β typically 5β50 visible nodes at any one time, even with hundreds in the underlying dataset.
- 20β50 underlying nodes: No perceptible cost. Click β expand β re-layout in <16 ms.
- 50β200 underlying nodes: Layout takes 30β80 ms on expand, mainly Unovis's iterative node-ranking algorithm. Still feels instant.
- 200+ underlying nodes: You'll start to notice the click β render delay. Mitigation: keep the initial state mostly collapsed, so layout always operates on a small visible set.
The bottleneck isn't expansion (Unovis handles it well) β it's the click-handler reactivity dance. Each toggle assignment triggers a full Unovis re-render. There's no cheaper path; Sankey layouts are inherently global (changing one node's connections shifts every other node's y-position).
There's also a known usability issue at small viewport widths: Sankey diagrams need horizontal space. The component pins min-width: 800px and the demo wraps it in a horizontal scroller, accepting that mobile users will swipe.
State Flow Diagram
ββββββββββββββββββββββββββ
β initial render β
β all top-level nodes β
β visible; expandables β
β collapsed by default β
β aggregate links shown β
ββββββββββββββ¬ββββββββββββ
β
ββββββββββββββββββΌβββββββββββββββββ
β click expand- β hover any node β click expanded
β able node β β node again
βΌ β βΌ
ββββββββββββββββββββ β βββββββββββββββββββββββ
β node.expanded = β β β collapse(node) β
β true β β β recursively also β
β children visible β β β collapses every β
β aggregate link β β β expanded descendant β
β hides β β β aggregate link β
β detail links β β β returns β
β appear β β β children hide β
β Unovis re-layout β β β Unovis re-layout β
ββββββββββββββββββββ β βββββββββββββββββββββββ
βΌ
ββββββββββββββββββ
β tooltip shown β
β (provided by β
β Unovis) β
ββββββββββββββββββProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
nodes |
SankeyNode[] |
required | All nodes including hidden children. Top-level nodes have no parent. |
links |
SankeyLink[] |
required | All flows. Includes both aggregate links (used when source collapsed) and detail links (used when source expanded). |
height |
number |
600 |
Container height in pixels. Width is 100% of parent (with min-width: 800px floor). |
SankeyNode:
interface SankeyNode {
id: string;
label: string;
color?: string; // hex; flows out of this node inherit it
expandable?: boolean; // does clicking expand this node?
expanded?: boolean; // current state (managed internally; pass false initially)
parent?: string; // id of parent node, if this is a child
}SankeyLink:
interface SankeyLink {
source: string; // id of source node
target: string; // id of target node
value: number; // flow magnitude; drives ribbon thickness
}Edge Cases
| Situation | Behaviour |
|---|---|
Empty nodes and links |
Unovis renders an empty container. No errors. |
Node with expandable: true but no children |
Cursor still becomes pointer; clicking sets expanded = true but no children appear. Don't do this β it confuses users. Set expandable: false if there are no children. |
| Aggregate link value β sum of detail link values | No crash. The diagram changes slightly between collapsed/expanded. Audit your data; the values must reconcile. |
| Link references a non-existent node id | Unovis silently drops the link. Check the console for warnings if flows look incomplete. |
| Cyclic flows (A β B, B β A) | Sankey is directional; cycles produce undefined layout behaviour. Use a DAG only. |
| Recursive collapse | collapse(grandparent) walks descendants depth-first and collapses each in turn. State is consistent after a single click. |
| Expanding a node whose children are themselves expandable | Children appear collapsed by default. User can click again to drill another level. |
| Mobile viewport (<800 px) | Container has min-width: 800px; demo wraps it in overflow-x: auto. Users swipe horizontally. |
| Keyboard navigation | Not implemented β Unovis renders to SVG without focusable nodes. This is documented as a limitation. Use a different component (or contribute a focus-management overlay) for keyboard-critical flows. |
prefers-reduced-motion: reduce |
Unovis honours its own internal motion settings; the component does not add CSS transitions of its own. |
| Hover overlap on stacked links | Unovis handles it via z-stacking; tooltips show the topmost link's data. Increase nodePadding if collisions are frequent. |
Dependencies
@unovis/svelte+@unovis/tsβ providesVisSingleContainer,VisSankey, the layout algorithm, link curves, tooltips, and SVG rendering. Why external? A native Sankey layout requires iterative node-ranking, link-routing through ports, and curve generation; building it from scratch is a 100+ hour project, and Unovis is a well-maintained library specifically optimised for D3-flavoured visualisations../sankeyData.tsβ local helper that exposes the visibility-awareexpand/collapseAPI. Hand-rolled in this repo (~80 lines).- Svelte 5.x β
$state,$propsfor the reactivity dance.
File Structure
src/lib/components/ExpandableSankey.svelte # the component (named ExpandableSankey, route /sankey)
src/lib/components/sankeyData.ts # createSankeyData() β visibility manager
src/lib/components/Sankey.md # this file
src/routes/sankey/+page.svelte # demo page
src/routes/sankey/+page.server.ts # SSR data load (with fallback)
src/lib/server/sankey.ts # loadSankeyDataFromDatabase + fallback
src/lib/types.ts # SankeyNode, SankeyLink, SankeyData, ExpandableSankeyProps
src/lib/constants.ts # FALLBACK_SANKEY_DATAAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
nodes | SankeyNode[] | required | All nodes including hidden children. Top-level nodes have no parent. |
links | SankeyLink[] | required | All flows. Aggregate links show when collapsed; detail links show when expanded. |
height | number | 600 | Container height in pixels. |