Skip to content

Sankey Chart ​

Weighted flows between columns of nodes; ribbons draw in and hovering traces a node's whole path.

Product$42.6BServices$18.4BLicensing$7.9BRevenue$68.9BGross$42.7BCosts$26.2BOperating$17.9BR&D$11.5BMarketing$9.4BG&A$3.8BNet income$14.5BTax$3.4B

Installation ​

pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/sankey-chart.json
Manual installation

Install the dependencies:

bash
npm install motion-v

Then copy these files into components/ui/sankey-chart/ (plus lib/utils.ts if you don't have it):

vue
<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>
ts
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`
}
ts
export type { SankeyLink, SankeyNode } from './layout'
export { default as SankeyChart } from './SankeyChart.vue'

Usage ​

ts
import { SankeyChart, type SankeyLink, type SankeyNode } from '@/components/ui/sankey-chart'
vue
<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 ​

PropTypeDefaultDescription
nodesSankeyNode[]—{ id, label?, color? }. color is any CSS color; nodes cycle through --chart-1…5 by default.
linksSankeyLink[]—{ source, target, value } by node id.
heightnumber360Height in px; width follows the container.
nodeWidthnumber12Width of the node bars.
nodePaddingnumber18Vertical gap between nodes in a column.
iterationsnumber8Relaxation passes.
localestring'en-US'Used by the default value formatter.
formatValue(v) => stringcompact numberLabels 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.

Designed by Kuldeep Pisda · Released under the MIT License