Sankey
Flow diagram for multi-stage data: nodes split into layers, paths drawn to scale as cubic Bezier curves, with an sr-only table as the screen-reader fallback.
Playground
| Source | Target | Value |
|---|---|---|
| blocks | form | 17 |
| blocks | display | 15 |
| blocks | ai | 10 |
| blocks | layout | 10 |
| blocks | action | 8 |
| blocks | navigation | 8 |
| blocks | overlay | 8 |
| blocks | feedback | 7 |
| auth | form | 8 |
| auth | feedback | 4 |
| auth | display | 1 |
| auth | data | 1 |
| table | data | 1 |
| form | stable | 20 |
| form | beta | 5 |
| display | beta | 11 |
| display | stable | 5 |
| ai | experimental | 10 |
| layout | stable | 7 |
| layout | experimental | 2 |
| layout | beta | 1 |
| action | stable | 6 |
| action | beta | 2 |
| navigation | stable | 5 |
| navigation | beta | 3 |
| overlay | stable | 4 |
| overlay | beta | 4 |
| feedback | stable | 10 |
| feedback | beta | 1 |
| data | stable | 2 |
<script lang="ts">
import { Sankey } from '@urbicon-ui/blocks';
const nodes = [
{ id: 'blocks', label: 'blocks', intent: 'primary' },
{ id: 'auth', label: 'auth', intent: 'secondary' },
{ id: 'table', label: 'table', intent: 'neutral' },
{ id: 'form', label: 'form' },
{ id: 'display', label: 'display' },
{ id: 'feedback', label: 'feedback' },
{ id: 'ai', label: 'ai' },
{ id: 'layout', label: 'layout' },
{ id: 'navigation', label: 'navigation' },
{ id: 'overlay', label: 'overlay' },
{ id: 'action', label: 'action' },
{ id: 'data', label: 'data' },
{ id: 'stable', label: 'stable', intent: 'success' },
{ id: 'beta', label: 'beta', intent: 'warning' },
{ id: 'experimental', label: 'experimental', intent: 'neutral' }
];
const links = [
{ source: 'blocks', target: 'form', value: 17 },
{ source: 'blocks', target: 'display', value: 15 },
{ source: 'blocks', target: 'ai', value: 10 },
{ source: 'blocks', target: 'layout', value: 10 },
{ source: 'blocks', target: 'action', value: 8 },
{ source: 'blocks', target: 'navigation', value: 8 },
{ source: 'blocks', target: 'overlay', value: 8 },
{ source: 'blocks', target: 'feedback', value: 7 },
{ source: 'auth', target: 'form', value: 8 },
{ source: 'auth', target: 'feedback', value: 4 },
{ source: 'auth', target: 'display', value: 1 },
{ source: 'auth', target: 'data', value: 1 },
{ source: 'table', target: 'data', value: 1 },
{ source: 'form', target: 'stable', value: 20 },
{ source: 'form', target: 'beta', value: 5 },
{ source: 'display', target: 'beta', value: 11 },
{ source: 'display', target: 'stable', value: 5 },
{ source: 'ai', target: 'experimental', value: 10 },
{ source: 'layout', target: 'stable', value: 7 },
{ source: 'layout', target: 'experimental', value: 2 },
{ source: 'layout', target: 'beta', value: 1 },
{ source: 'action', target: 'stable', value: 6 },
{ source: 'action', target: 'beta', value: 2 },
{ source: 'navigation', target: 'stable', value: 5 },
{ source: 'navigation', target: 'beta', value: 3 },
{ source: 'overlay', target: 'stable', value: 4 },
{ source: 'overlay', target: 'beta', value: 4 },
{ source: 'feedback', target: 'stable', value: 10 },
{ source: 'feedback', target: 'beta', value: 1 },
{ source: 'data', target: 'stable', value: 2 }
];
</script>
<Sankey
{nodes}
{links}
height={440}
nodePadding={12}
/>01 Examples
5-Stage Heating Cost Flow
| Source | Target | Value |
|---|---|---|
| Gas bill | Heating cost pot | 2.206,09 € |
| Heat pump electricity | Heating cost pot | 1.127,31 € |
| Maintenance | Heating cost pot | 361,02 € |
| Chimney sweep | Heating cost pot | 128,00 € |
| Heating cost pot | Heating | 3.105,03 € |
| Heating cost pot | Hot water | 717,39 € |
| Heating | Unit 4 | 1.855,47 € |
| Heating | Unit 4A | 1.249,56 € |
| Hot water | Unit 4 | 394,77 € |
| Hot water | Unit 4A | 322,62 € |
<div class="w-full max-w-4xl">
<Sankey nodes={heatingNodes} links={heatingLinks} formatValue={formatEur} height={500} />
</div>Salary Breakdown
| Source | Target | Value |
|---|---|---|
| Gross salary | Income tax | 850 € |
| Gross salary | Social contributions | 1.080 € |
| Social contributions | Health insurance | 360 € |
| Social contributions | Pension insurance | 480 € |
| Social contributions | Unemployment ins. | 90 € |
| Social contributions | Long-term care | 150 € |
| Gross salary | Net pay | 2.570 € |
<div class="w-full max-w-3xl">
<Sankey
nodes={salaryNodes}
links={salaryLinks}
formatValue={(v) => `${v.toLocaleString('de-DE')} €`}
height={400}
/>
</div>Node Alignments
justify
| Source | Target | Value |
|---|---|---|
| Source | Mid | 60 |
| Mid | A | 60 |
| Source | B (early sink) | 30 |
left
| Source | Target | Value |
|---|---|---|
| Source | Mid | 60 |
| Mid | A | 60 |
| Source | B (early sink) | 30 |
right
| Source | Target | Value |
|---|---|---|
| Source | Mid | 60 |
| Mid | A | 60 |
| Source | B (early sink) | 30 |
center
| Source | Target | Value |
|---|---|---|
| Source | Mid | 60 |
| Mid | A | 60 |
| Source | B (early sink) | 30 |
{#each ['justify', 'left', 'right', 'center'] as align (align)}
<div class="border-border-subtle bg-surface-elevated rounded-contain border p-3">
<h4 class="text-text-secondary mb-2 text-xs font-medium tracking-wide uppercase">
{align}
</h4>
<Sankey
nodes={alignmentNodes}
links={alignmentLinks}
formatValue={(v) => `${v}`}
height={180}
nodeAlign={align as 'justify' | 'left' | 'right' | 'center'}
/>
</div>
{/each}Compact Embedding
Cashflow Overview
Q1 2026
| Source | Target | Value |
|---|---|---|
| Source | Target | 100 % |
<div class="border-border-subtle bg-surface-elevated w-full max-w-md rounded-2xl border p-5">
<header class="mb-3">
<h3 class="text-text-primary text-sm font-semibold">Cashflow Overview</h3>
<p class="text-text-tertiary text-xs">Q1 2026</p>
</header>
<Sankey
nodes={minimalNodes}
links={minimalLinks}
formatValue={(v) => `${v} %`}
height={140}
nodeWidth={16}
nodePadding={10}
/>
</div>02 Customization
Custom Tooltip
| Source | Target | Value |
|---|---|---|
| Gas bill | Heating cost pot | 2.206,09 € |
| Heat pump electricity | Heating cost pot | 1.127,31 € |
| Maintenance | Heating cost pot | 361,02 € |
| Chimney sweep | Heating cost pot | 128,00 € |
| Heating cost pot | Heating | 3.105,03 € |
| Heating cost pot | Hot water | 717,39 € |
| Heating | Unit 4 | 1.855,47 € |
| Heating | Unit 4A | 1.249,56 € |
| Hot water | Unit 4 | 394,77 € |
| Hot water | Unit 4A | 322,62 € |
<div class="w-full max-w-3xl">
<Sankey nodes={heatingNodes} links={heatingLinks} formatValue={formatEur} height={400}>
{#snippet tooltip(datum, kind)}
{#if kind === 'node'}
{@const node = datum as { id: string; value: number }}
<span class="block font-medium">{node.id}</span>
<span class="text-text-tertiary text-2xs block tabular-nums">
{formatEur(node.value)}
</span>
{:else}
{@const link = datum as {
source: { id: string };
target: { id: string };
value: number;
}}
<span class="block font-medium">
{link.source.id} → {link.target.id}
</span>
<span class="text-primary text-2xs block font-semibold tabular-nums">
{formatEur(link.value)}
</span>
{/if}
{/snippet}
</Sankey>
</div>03 Accessibility
SVG with role="img"
The SVG has role="img" with an aria-label that lists every connection ("Source → Target:
value"). A screen reader announces the diagram in a single block.
Table Fallback
An sr-only table (source / target / value) is rendered in addition — screen readers that prefer tables can query the data row by row.
Keyboard Navigation
Tab focuses every node and link in sequence. On a focused element, Enter/Space triggers the click handler (onNodeClick / onLinkClick).
Hover + Focus Highlight
Hovering or focusing a node dims all unconnected paths and nodes and highlights its direct connections.
04 API Reference
Prop | Type | Default | Description | |
|---|---|---|---|---|
links required | SankeyLink[] | — | Links property for the Sankey component | |
nodes required | SankeyNode[] | — | Nodes property for the Sankey component | |
class | string | — | Extra classes merged onto the wrapper. | |
defaultOpacity | number | 0.45 | Default stroke opacity of paths without hover. | |
dimmedOpacity | number | 0.25 | Hover opacity of the non-focused paths. | |
formatPercent | (percent: number) => string | — | Format function for percentages (tooltip). | |
formatValue | (value: number) => string | — | Format function for values (tooltip + sr-only table). | |
height | auto | 400 | Height of the diagram. Accepts a fixed pixel number or 'auto'.
'auto' scales with the node count: roughly nodes.length × (12 + nodePadding),
clamped to [280, 800]. Useful when the number of nodes varies at
runtime and a fixed pixel value would be too small or too large. | |
highlightedOpacity | number | 0.7 | Stroke opacity of the highlighted paths. | |
highlightOnHover | boolean | true | Hover highlight (paths + connected nodes). | |
intent | SankeyIntent | 'neutral' | Default intent for nodes without their own intent. | |
iterations | number | 6 | Number of relaxation iterations in the layout. | |
linkContent | Snippet<[link: SankeyLaidOutLinkWithMeta]> | — | Custom snippet for path rendering (instead of the default cubic Bezier). | |
nodeAlign | leftrightcenterjustify | 'justify' | Where nodes go that the layering leaves short of the last layer.
Layers come from the **longest** path to a node, so this moves only two
kinds: right and justify push sinks (no outgoing links) to the last
layer, center puts fully isolated nodes (no links at all) in the middle,
left moves nothing. On an evenly layered graph — every source-to-sink
path the same length — all four values render identically.
Not d3-sankey's nodeAlign, which positions every node. | |
nodeContent | Snippet<[node: SankeyLaidOutNodeWithMeta]> | — | Custom snippet for node content (inside the SVG, instead of the label). | |
nodePadding | number | 16 | Vertical gap between nodes within a layer. | |
nodeWidth | number | 24 | Pixel width of a node. | |
onLinkClick | (link: SankeyLaidOutLinkWithMeta) => void | — | Click callback for a path. | |
onNodeClick | (node: SankeyLaidOutNodeWithMeta) => void | — | Click callback for a node. | |
preset | string | — | Preset property for the Sankey component | |
showValues | boolean | false | Persistently display values next to node labels (instead of only in the
hover tooltip). Useful for print, PDF attachments, static screenshots,
or tenant- and client-facing statements where hovering is not possible.
Uses formatValue for formatting. | |
slotClasses | Partial<Record<SankeySlots, string>> | — | Per-slot class overrides. | |
tooltip | Snippet<[datum: SankeyLaidOutNodeWithMeta | SankeyLaidOutLinkWithMeta, kind: 'node' | 'link']> | — | Custom tooltip content for node or path. | |
unstyled | boolean | — | Remove default classes. | |
width | number | — | Optional fixed width. Default: derived from the container via ResizeObserver. Set a value only if you need SSR-stable layouts and are willing to give up responsiveness. | |
...HTMLAttributes<HTMLDivElement> inherited | HTMLAttributes | — | HTML attributes (excluding: 'children') | |
...SankeyVariants variant | VariantProps | — | Styling variants from SankeyVariants |
05 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
SankeyIntent | type | helper | 1 | — | |
SankeyNode | interface | helper | 1 | Input node of the Sankey component. | |
SankeyLink | interface | helper | 1 | Input link of the Sankey component. | |
SankeyLaidOutNodeWithMeta | type | helper | 0 | Node computed by the layout — passed through to custom snippets. | |
SankeyLaidOutLinkWithMeta | type | helper | 0 | — | |
SankeyProps | interface | props | 0 | — | |
SankeyVariants | type | variant | 0 | — | |
SankeySlots | type | variant | 0 | Slot names derived from the tv() config above — single source of truth for slotClasses. | |
SankeyLaidOutLink | interface | helper | 0 | — | |
SankeyLaidOutNode | interface | helper | 0 | — |
06 Installation
Import
import { Sankey } from '@urbicon-ui/blocks';