Sankey Chart
Weighted flows between columns of nodes; ribbons draw in and hovering traces a node's whole path.
Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/sankey-chart.jsonManual installation
Install the dependencies:
npm install motion-vThen copy these files into components/ui/sankey-chart/ (plus lib/utils.ts if you don't have it):
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { PlacedLink, PlacedNode, SankeyLink, SankeyNode } from './layout'
import { AnimatePresence, motion, useReducedMotion } from 'motion-v'
import { computed, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
import { cn } from '@/lib/utils'
import { layoutSankey, ribbonPath } from './layout'
const props = withDefaults(defineProps<{
nodes: SankeyNode[]
links: SankeyLink[]
/** Chart height in px. Width follows the container. */
height?: number
/** Width of the node bars. */
nodeWidth?: number
/** Vertical gap between nodes in a column. */
nodePadding?: number
/** Relaxation passes — more passes, straighter ribbons. */
iterations?: number
/** `left`: each node sits at its depth. `justify`: end nodes line up in the last column. */
align?: 'left' | 'justify'
locale?: string
formatValue?: (value: number) => string
class?: HTMLAttributes['class']
}>(), {
height: 360,
nodeWidth: 12,
nodePadding: 18,
iterations: 8,
align: 'left',
locale: 'en-US',
})
const STAGGER = 0.22 // seconds between columns
const WIPE = { duration: 0.5, ease: [0.45, 0, 0.25, 1] } as const // + STAGGER per column
const TIP = { type: 'spring', stiffness: 520, damping: 38, mass: 0.6 } as const
const uid = `vx-sankey-${useId()}`
const root = ref<HTMLElement>()
const width = ref(640)
const prefersReduced = useReducedMotion()
// useReducedMotion() only settles after mount, so also read the media query directly: animations
// that start on mount get skipped too. Only transitions use it — `initial` must match the server render.
const reduce = computed(() => prefersReduced.value
|| (typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches))
const orInstant = <T>(t: T) => (reduce.value ? { duration: 0 } : t)
const fmt = computed(() => {
if (props.formatValue)
return props.formatValue
const f = new Intl.NumberFormat(props.locale, { maximumFractionDigits: 1, notation: 'compact' })
return (v: number) => f.format(v)
})
const pct = (v: number) => `${(v * 100).toFixed(v < 0.1 ? 1 : 0)}%`
// Rough text width so outer labels get enough margin (12px UI font ≈ 6.2px per glyph).
const GLYPH = 6.2
const textWidth = (s: string) => s.length * GLYPH
const STACK_MIN = 26 // nodes at least this tall get a two-line label
const options = (left: number, right: number) => ({
left,
right: width.value - right,
top: 6,
bottom: props.height - 6,
nodeWidth: props.nodeWidth,
nodePadding: props.nodePadding,
iterations: props.iterations,
align: props.align,
})
// Outer columns keep their labels outside the flow, so they need margins sized to the text.
const graph = computed(() => {
const labelW = (n: PlacedNode) => {
const name = textWidth(n.label)
const value = textWidth(fmt.value(n.value))
return (n.y1 - n.y0 >= STACK_MIN ? Math.max(name, value) : name + value + 5) + 12
}
const cap = width.value * 0.26
const margins = (g: ReturnType<typeof layoutSankey>) => {
const outer = (col: number) => Math.max(0, ...g.nodes.filter(n => n.column === col).map(labelW))
return [Math.min(cap, outer(0)), g.columns > 1 ? Math.min(cap, outer(g.columns - 1)) : 0] as const
}
// Pass 1 assumes stacked labels; pass 2 corrects for nodes that turned out short.
let [left, right] = [cap / 2, cap / 2]
let g = layoutSankey(props.nodes, props.links, options(left, right))
for (let pass = 0; pass < 2; pass++) {
const [l, r] = margins(g)
if (Math.abs(l - left) < 1 && Math.abs(r - right) < 1)
break
;[left, right] = [l, r]
g = layoutSankey(props.nodes, props.links, options(left, right))
}
return g
})
const total = computed(() => graph.value.nodes.filter(n => n.column === 0).reduce((s, n) => s + n.value, 0) || 1)
// Labels between columns are cut to the space before the next column (full name in the tooltip).
function fit(text: string, max: number) {
const chars = Math.floor(max / GLYPH)
return text.length <= chars ? text : `${text.slice(0, Math.max(1, chars - 1)).trimEnd()}…`
}
const labels = computed(() => {
const { nodes, columns } = graph.value
const step = columns > 1 ? (nodes.find(n => n.column === 1)?.x0 ?? 0) - (nodes.find(n => n.column === 0)?.x0 ?? 0) : width.value
return nodes.map((n) => {
const first = n.column === 0 && columns > 1
const middle = n.column > 0 && n.column < columns - 1
const stacked = n.y1 - n.y0 >= STACK_MIN
const room = step - props.nodeWidth - 10
const value = fmt.value(n.value)
return {
node: n,
x: first ? n.x0 - 8 : n.x1 + 8,
y: (n.y0 + n.y1) / 2,
anchor: first ? 'end' : 'start',
stacked,
text: middle ? fit(n.label, stacked ? room : room - textWidth(value) - 5) : n.label,
hidden: middle && room < 46,
value,
// Labels that sit over ribbons get a halo.
halo: middle,
}
})
})
// Replays the draw-in whenever the data changes (not on resize).
const version = ref(0)
watch(() => [props.nodes, props.links], () => version.value++, { deep: true })
// --- hover ----------------------------------------------------------------------
const hover = ref<{ type: 'node' | 'link', index: number } | null>(null)
const pointer = ref({ x: 0, y: 0 })
const lit = computed(() => {
const h = hover.value
if (!h)
return null
const nodes = new Set<PlacedNode>()
const links = new Set<PlacedLink>()
if (h.type === 'link') {
const l = graph.value.links[h.index]
if (l) {
links.add(l)
nodes.add(l.source).add(l.target)
}
return { nodes, links }
}
const start = graph.value.nodes[h.index]
if (!start)
return null
// Everything that flows into the node and everything that flows out of it.
const walk = (n: PlacedNode, dir: 'incoming' | 'outgoing') => {
nodes.add(n)
for (const l of n[dir]) {
if (links.has(l))
continue
links.add(l)
walk(dir === 'incoming' ? l.source : l.target, dir)
}
}
walk(start, 'incoming')
walk(start, 'outgoing')
return { nodes, links }
})
const linkOpacity = (l: PlacedLink) => (!lit.value ? 0.4 : lit.value.links.has(l) ? 0.72 : 0.08)
const nodeOpacity = (n: PlacedNode) => (!lit.value || lit.value.nodes.has(n) ? 1 : 0.3)
const tooltip = computed(() => {
const h = hover.value
if (!h)
return null
if (h.type === 'link') {
const l = graph.value.links[h.index]
if (!l)
return null
return {
color: l.source.color,
title: `${l.source.label} → ${l.target.label}`,
value: fmt.value(l.value),
note: `${pct(l.value / (l.source.outValue || 1))} of ${l.source.label}`,
}
}
const n = graph.value.nodes[h.index]
if (!n)
return null
return {
color: n.color,
title: n.label,
value: fmt.value(n.value),
note: n.inValue && n.outValue
? `in ${fmt.value(n.inValue)} · out ${fmt.value(n.outValue)}`
: `${pct(n.value / total.value)} of total`,
}
})
// Keep the tooltip inside the chart: flip to the other side of the pointer near the edges.
const tip = computed(() => {
const flipX = pointer.value.x > width.value - 200
const flipY = pointer.value.y > props.height - 90
return {
x: pointer.value.x + (flipX ? -14 : 14),
y: pointer.value.y + (flipY ? -14 : 14),
flipX,
flipY,
}
})
function onPointerMove(e: PointerEvent) {
const r = root.value!.getBoundingClientRect()
pointer.value = { x: e.clientX - r.left, y: e.clientY - r.top }
}
const summary = computed(() => {
const { nodes, links } = graph.value
const top = [...links].sort((a, b) => b.value - a.value)[0]
return `Sankey diagram: ${links.length} flows between ${nodes.length} nodes, ${fmt.value(total.value)} in total.${
top ? ` Largest flow: ${top.source.label} to ${top.target.label}, ${fmt.value(top.value)}.` : ''}`
})
let ro: ResizeObserver | undefined
onMounted(() => {
ro = new ResizeObserver(([entry]) => {
if (entry)
width.value = Math.max(280, Math.round(entry.contentRect.width))
})
ro.observe(root.value!)
})
onBeforeUnmount(() => ro?.disconnect())
</script>
<template>
<div
ref="root"
:class="cn('relative w-full text-foreground', props.class)"
@pointermove="onPointerMove"
@pointerleave="hover = null"
>
<svg
:width="width"
:height="props.height"
:viewBox="`0 0 ${width} ${props.height}`"
role="img"
:aria-label="summary"
class="block select-none overflow-visible"
>
<defs>
<linearGradient
v-for="l in graph.links"
:id="`${uid}-g${l.index}`"
:key="l.index"
gradientUnits="userSpaceOnUse"
:x1="l.source.x1"
:x2="l.target.x0"
y1="0"
y2="0"
>
<stop offset="0%" :stop-color="l.source.color" />
<stop offset="100%" :stop-color="l.target.color" />
</linearGradient>
</defs>
<!-- background: moving off a ribbon or node clears the highlight -->
<rect :width="width" :height="props.height" fill="transparent" @pointerenter="hover = null" />
<g :key="version">
<!-- ribbons, revealed by a left-to-right wipe that keeps pace with the nodes -->
<clipPath :id="`${uid}-wipe`">
<motion.rect
x="0"
y="0"
:width="width"
:height="props.height"
:style="{ originX: 0 }"
:initial="{ scaleX: 0 }"
:animate="{ scaleX: 1 }"
:transition="orInstant({ ...WIPE, duration: WIPE.duration + graph.columns * STAGGER, delay: 0.1 })"
/>
</clipPath>
<g :clip-path="`url(#${uid}-wipe)`">
<path
v-for="l in graph.links"
:key="l.index"
:d="ribbonPath(l)"
:fill="`url(#${uid}-g${l.index})`"
class="cursor-pointer transition-opacity duration-200"
:style="{ opacity: linkOpacity(l) }"
@pointerenter="hover = { type: 'link', index: l.index }"
/>
</g>
<!-- nodes -->
<g
v-for="n in graph.nodes"
:key="n.id"
class="cursor-pointer transition-opacity duration-200"
:style="{ opacity: nodeOpacity(n) }"
@pointerenter="hover = { type: 'node', index: n.index }"
>
<motion.rect
:x="n.x0"
:y="n.y0"
:width="n.x1 - n.x0"
:height="n.y1 - n.y0"
rx="2"
:style="{ fill: n.color }"
:initial="{ scaleY: 0, opacity: 0 }"
:animate="{ scaleY: 1, opacity: 1 }"
:transition="orInstant({ type: 'spring', stiffness: 260, damping: 24, delay: n.column * STAGGER })"
/>
<!-- generous invisible hit area -->
<rect :x="n.x0 - 4" :y="n.y0" :width="n.x1 - n.x0 + 8" :height="n.y1 - n.y0" fill="transparent" />
</g>
<!-- labels -->
<motion.g
class="pointer-events-none text-xs"
:initial="{ opacity: 0 }"
:animate="{ opacity: 1 }"
:transition="orInstant({ duration: 0.4, delay: 0.35 + (graph.columns - 1) * STAGGER })"
>
<g
v-for="t in labels.filter(l => !l.hidden)"
:key="t.node.id"
class="transition-opacity duration-200"
:style="{ opacity: nodeOpacity(t.node) }"
:text-anchor="t.anchor"
>
<text
:x="t.x"
:y="t.stacked ? t.y - 7 : t.y"
dy="0.35em"
class="fill-foreground font-medium"
:class="t.halo && 'stroke-background [paint-order:stroke] [stroke-linejoin:round]'"
:stroke-width="t.halo ? 3 : 0"
>
{{ t.text }}<tspan v-if="!t.stacked" dx="5" class="fill-muted-foreground font-normal tabular-nums">{{ t.value }}</tspan>
</text>
<text
v-if="t.stacked"
:x="t.x"
:y="t.y + 8"
dy="0.35em"
class="fill-muted-foreground text-[11px] tabular-nums"
:class="t.halo && 'stroke-background [paint-order:stroke] [stroke-linejoin:round]'"
:stroke-width="t.halo ? 3 : 0"
>
{{ t.value }}
</text>
</g>
</motion.g>
</g>
</svg>
<AnimatePresence>
<motion.div
v-if="tooltip"
key="tip"
aria-hidden="true"
class="pointer-events-none absolute left-0 top-0 z-10"
:initial="{ opacity: 0, scale: 0.96, x: tip.x, y: tip.y }"
:animate="{ opacity: 1, scale: 1, x: tip.x, y: tip.y }"
:exit="{ opacity: 0, scale: 0.96, transition: { duration: 0.12 } }"
:transition="reduce ? { duration: 0 } : TIP"
>
<div
class="whitespace-nowrap rounded-md border bg-popover px-2.5 py-1.5 text-xs text-popover-foreground shadow-md"
:class="[tip.flipX && '-translate-x-full', tip.flipY && '-translate-y-full']"
>
<div class="flex items-center gap-1.5 font-medium">
<span class="size-2 rounded-full" :style="{ background: tooltip.color }" />
{{ tooltip.title }}
</div>
<div class="mt-0.5 flex items-baseline gap-2 tabular-nums">
<span class="text-sm font-semibold">{{ tooltip.value }}</span>
<span class="text-muted-foreground">{{ tooltip.note }}</span>
</div>
</div>
</motion.div>
</AnimatePresence>
</div>
</template>export interface SankeyNode {
id: string
/** Display name (defaults to the id). */
label?: string
/** Any CSS color. Defaults to cycling through `--chart-1…5`. */
color?: string
}
export interface SankeyLink {
source: string
target: string
value: number
}
export interface PlacedNode {
id: string
label: string
color: string
index: number
column: number
value: number
inValue: number
outValue: number
x0: number
x1: number
y0: number
y1: number
incoming: PlacedLink[]
outgoing: PlacedLink[]
}
export interface PlacedLink {
index: number
source: PlacedNode
target: PlacedNode
value: number
width: number
/** Top edge of the ribbon where it leaves the source / enters the target. */
sy: number
ty: number
}
export interface SankeyOptions {
left: number
right: number
top: number
bottom: number
nodeWidth: number
nodePadding: number
iterations: number
/** `left`: nodes sit at their depth. `justify`: nodes without outgoing links move to the last column. */
align: 'left' | 'justify'
}
const center = (n: PlacedNode) => (n.y0 + n.y1) / 2
/**
* A small Sankey layout:
* 1. columns = longest path from a source (sinks are pushed to the last column),
* 2. nodes stacked per column with one shared value→px scale,
* 3. a few relaxation passes pull every node toward the weighted centre of its neighbours,
* resolving overlaps after each pass,
* 4. ribbons are ordered at each node by the vertical position of the node at the other end,
* which keeps crossings to a minimum.
*/
export function layoutSankey(inputNodes: SankeyNode[], inputLinks: SankeyLink[], o: SankeyOptions) {
const nodes: PlacedNode[] = inputNodes.map((n, index) => ({
id: n.id,
label: n.label ?? n.id,
color: n.color ?? `var(--chart-${(index % 5) + 1})`,
index,
column: 0,
value: 0,
inValue: 0,
outValue: 0,
x0: 0,
x1: 0,
y0: 0,
y1: 0,
incoming: [],
outgoing: [],
}))
const byId = new Map(nodes.map(n => [n.id, n]))
const links: PlacedLink[] = []
for (const l of inputLinks) {
const source = byId.get(l.source)
const target = byId.get(l.target)
if (!source || !target || source === target || !(l.value > 0))
continue
const link: PlacedLink = { index: links.length, source, target, value: l.value, width: 0, sy: 0, ty: 0 }
links.push(link)
source.outgoing.push(link)
target.incoming.push(link)
source.outValue += l.value
target.inValue += l.value
}
for (const n of nodes)
n.value = Math.max(n.inValue, n.outValue)
// 1. Columns (Kahn's algorithm; nodes caught in a cycle stay in column 0).
const pending = new Map(nodes.map(n => [n, n.incoming.length]))
const queue = nodes.filter(n => n.incoming.length === 0)
while (queue.length) {
const n = queue.shift()!
for (const l of n.outgoing) {
l.target.column = Math.max(l.target.column, n.column + 1)
pending.set(l.target, pending.get(l.target)! - 1)
if (pending.get(l.target) === 0)
queue.push(l.target)
}
}
const last = Math.max(0, ...nodes.map(n => n.column))
if (o.align === 'justify') {
for (const n of nodes) {
if (!n.outgoing.length && n.incoming.length)
n.column = last
}
}
const columns: PlacedNode[][] = Array.from({ length: last + 1 }, () => [])
for (const n of nodes)
columns[n.column]!.push(n)
// 2. Shared vertical scale + horizontal placement.
const height = o.bottom - o.top
const ky = Math.min(...columns.filter(c => c.length).map((c) => {
const total = c.reduce((s, n) => s + n.value, 0) || 1
return (height - (c.length - 1) * o.nodePadding) / total
}))
const colStep = last ? (o.right - o.left - o.nodeWidth) / last : 0
for (const col of columns) {
const total = col.reduce((s, n) => s + n.value * ky, 0) + (col.length - 1) * o.nodePadding
let y = o.top + (height - total) / 2
for (const n of col) {
n.x0 = o.left + n.column * colStep
n.x1 = n.x0 + o.nodeWidth
n.y0 = y
n.y1 = y + Math.max(1, n.value * ky)
y = n.y1 + o.nodePadding
}
}
for (const l of links)
l.width = Math.max(1, l.value * ky)
// 3. Relaxation.
const shift = (n: PlacedNode, dy: number) => {
n.y0 += dy
n.y1 += dy
}
const resolve = (col: PlacedNode[]) => {
col.sort((a, b) => a.y0 - b.y0)
let y = o.top
for (const n of col) {
if (n.y0 < y)
shift(n, y - n.y0)
y = n.y1 + o.nodePadding
}
y = o.bottom
for (let i = col.length - 1; i >= 0; i--) {
const n = col[i]!
if (n.y1 > y)
shift(n, y - n.y1)
y = n.y0 - o.nodePadding
}
}
const pull = (n: PlacedNode, ls: PlacedLink[], other: (l: PlacedLink) => PlacedNode, alpha: number) => {
const w = ls.reduce((s, l) => s + l.value, 0)
if (!w)
return
const target = ls.reduce((s, l) => s + center(other(l)) * l.value, 0) / w
shift(n, (target - center(n)) * alpha)
}
for (let i = 0; i < o.iterations; i++) {
const alpha = 0.9 * (1 - i / o.iterations) + 0.1
for (const col of columns.slice(1)) {
for (const n of col) pull(n, n.incoming, l => l.source, alpha)
resolve(col)
}
for (const col of columns.slice(0, -1).reverse()) {
for (const n of col) pull(n, n.outgoing, l => l.target, alpha)
resolve(col)
}
}
// 4. Ribbon order at each node, centred within the node's height.
for (const n of nodes) {
n.outgoing.sort((a, b) => a.target.y0 - b.target.y0 || a.target.index - b.target.index)
n.incoming.sort((a, b) => a.source.y0 - b.source.y0 || a.source.index - b.source.index)
let y = n.y0 + (n.y1 - n.y0 - n.outValue * ky) / 2
for (const l of n.outgoing) {
l.sy = y
y += l.width
}
y = n.y0 + (n.y1 - n.y0 - n.inValue * ky) / 2
for (const l of n.incoming) {
l.ty = y
y += l.width
}
}
return { nodes, links, columns: last + 1 }
}
/**
* A ribbon as a filled shape: along the top edge from source to target, back along the bottom
* edge. Filled (rather than a thick stroke) so it stays clean when columns are close together.
*/
export function ribbonPath(l: PlacedLink) {
const x0 = l.source.x1
const x1 = l.target.x0
const xm = (x0 + x1) / 2
const [s0, s1, t0, t1] = [l.sy, l.sy + l.width, l.ty, l.ty + l.width]
return `M${x0},${s0} C${xm},${s0} ${xm},${t0} ${x1},${t0} L${x1},${t1} C${xm},${t1} ${xm},${s1} ${x0},${s1} Z`
}export type { SankeyLink, SankeyNode } from './layout'
export { default as SankeyChart } from './SankeyChart.vue'Usage
import { SankeyChart, type SankeyLink, type SankeyNode } from '@/components/ui/sankey-chart'<SankeyChart
:nodes="[
{ id: 'web', label: 'Web' },
{ id: 'app', label: 'App' },
{ id: 'orders', label: 'Orders' },
{ id: 'refunds', label: 'Refunds', color: 'var(--chart-5)' },
{ id: 'kept', label: 'Kept' },
]"
:links="[
{ source: 'web', target: 'orders', value: 620 },
{ source: 'app', target: 'orders', value: 380 },
{ source: 'orders', target: 'refunds', value: 70 },
{ source: 'orders', target: 'kept', value: 930 },
]"
/>Layout
Columns come from the longest path to each node; nodes without outgoing links are pushed to the last column. Nodes start stacked in the order you pass them, then a few relaxation passes pull each one toward the weighted centre of its neighbours, and ribbons are ordered at every node by the position of the node at their other end. The input must be acyclic.
API
| Prop | Type | Default | Description |
|---|---|---|---|
nodes | SankeyNode[] | — | { id, label?, color? }. color is any CSS color; nodes cycle through --chart-1…5 by default. |
links | SankeyLink[] | — | { source, target, value } by node id. |
height | number | 360 | Height in px; width follows the container. |
nodeWidth | number | 12 | Width of the node bars. |
nodePadding | number | 18 | Vertical gap between nodes in a column. |
iterations | number | 8 | Relaxation passes. |
locale | string | 'en-US' | Used by the default value formatter. |
formatValue | (v) => string | compact number | Labels and tooltip values. |
Ribbons are tinted with a gradient from the source to the target color. Hovering a node highlights everything that flows into and out of it; hovering a ribbon highlights that flow and its two nodes.