← Charts

Sankey Flow

A flow diagram. The nodes grow in column by column, ribbons draw from sources to destinations, each as thick as the amount it carries and shading from one node’s colour to the next, then a steady stream of dots keeps flowing along them.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-sankey-flow="From > To: amount, …" to an element with a height. Flows can chain (A > B, B > C) to make more columns.

Options

Add any of these attributes to the same element.

data-colorsComma-separated node colours, in order of first mention. Default purple, pink, blue…
data-prefix / data-suffixText around the amounts, e.g. $.
data-valuesfalse shows node names only.
data-streamfalse turns off the flowing dots.
data-durationTime for each ribbon to draw. Default 1000 ms.
data-loopClear and draw again every few seconds.

Point at a ribbon to pick it out. The dots pause while the diagram is off screen, and stay still with reduced motion. Screen readers hear every flow.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Sankey Flow: a flow diagram. The nodes grow in column by column, the
    // ribbons between them draw from sources to destinations (each as thick
    // as the amount it carries, shading from one node's colour to the next),
    // and then a steady stream of dots keeps flowing along them.
    // Usage:
    //   <div data-sankey-flow="Ads > Sign-ups: 30, Ads > Bounced: 20,
    //        Search > Sign-ups: 45, Search > Bounced: 10" style="height:260px"></div>
    // Each flow is "From > To: amount", separated by commas or semicolons.
    // Flows can chain (A > B, then B > C) to make more columns.
    // Optional: data-colors="#a855f7,#ec4899,…" (node colours, in order of
    // first mention), data-prefix / data-suffix (around the amounts),
    // data-values="false" (names only), data-stream="false" (no flowing dots),
    // data-duration (ms for the ribbons to draw, default 1000), data-loop
    // (clear and draw again every few seconds).
    var DEFAULTS = { duration: 1000, hold: 4000, node: 10, gap: 12 };
    var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1', '#f43f5e', '#14b8a6'];
    var uid = 0;

    var css =
      '.skf{position:relative}' +
      '.skf svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
      '.skf-node{transform-box:fill-box;transform-origin:50% 50%}' +
      '.skf-link{transition:opacity .25s}' +
      '.skf svg:hover .skf-link{opacity:.35}' +
      '.skf svg .skf-link:hover{opacity:1}' +
      '.skf-stream{fill:none;stroke:#fff;stroke-linecap:round;stroke-dasharray:0 14;pointer-events:none;opacity:0;' +
      'animation:skf-flow 1s linear infinite;animation-play-state:var(--skf-run,running)}' +
      '@keyframes skf-flow{to{stroke-dashoffset:-14}}' +
      '.skf-label{font-size:12px;font-weight:600;fill:currentColor;paint-order:stroke;stroke:rgba(255,255,255,.75);stroke-width:3px;stroke-linejoin:round}' +
      '.skf-label tspan{font-weight:500;fill-opacity:.6;font-variant-numeric:tabular-nums}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }

    function parse(el) {
      var nodes = [], byName = {}, links = [];
      function node(name) {
        if (!byName[name]) { byName[name] = { name: name, i: nodes.length, ins: [], outs: [], layer: 0 }; nodes.push(byName[name]); }
        return byName[name];
      }
      el.getAttribute('data-sankey-flow').split(/[;,\n]/).forEach(function (part) {
        var m = part.match(/^\s*(.+?)\s*>\s*(.+?)\s*:\s*([\d.]+)\s*$/);
        if (!m) return;
        var l = { s: node(m[1]), t: node(m[2]), v: +m[3] };
        l.s.outs.push(l);
        l.t.ins.push(l);
        links.push(l);
      });
      // Each node's column: one past the furthest of its sources.
      for (var pass = 0; pass < nodes.length; pass++) {
        links.forEach(function (l) { if (l.t.layer <= l.s.layer) l.t.layer = l.s.layer + 1; });
      }
      nodes.forEach(function (n) {
        var sin = n.ins.reduce(function (a, l) { return a + l.v; }, 0);
        var sout = n.outs.reduce(function (a, l) { return a + l.v; }, 0);
        n.v = Math.max(sin, sout);
      });
      return { nodes: nodes, links: links, layers: Math.max.apply(null, nodes.map(function (n) { return n.layer; })) + 1 };
    }

    function setup(el) {
      var d = el.dataset;
      var g = parse(el);
      var colors = d.colors ? d.colors.split(',') : PALETTE;
      g.nodes.forEach(function (n) { n.color = colors[n.i % colors.length]; });
      var pre = d.prefix || '', suf = d.suffix || '';
      el.classList.add('skf');
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', 'Flow diagram: ' + g.links.map(function (l) {
        return l.s.name + ' to ' + l.t.name + ', ' + pre + l.v.toLocaleString() + suf;
      }).join('; ') + '.');
      var c = { el: el, g: g, id: 'skf' + uid++, pre: pre, suf: suf, played: false };
      el._skf = c;
      build(c);
      return c;
    }

    // Lay the diagram out in real pixels and draw it.
    function build(c) {
      var d = c.el.dataset, g = c.g;
      var W = c.el.clientWidth, H = c.el.clientHeight;
      c.w = W; c.h = H;
      var nw = DEFAULTS.node, gap = DEFAULTS.gap;
      var cols = [];
      g.nodes.forEach(function (n) { (cols[n.layer] = cols[n.layer] || []).push(n); });
      var k = Infinity;
      cols.forEach(function (col) {
        var sum = col.reduce(function (a, n) { return a + n.v; }, 0);
        k = Math.min(k, (H - gap * (col.length - 1)) / (sum || 1));
      });
      cols.forEach(function (col, li) {
        var used = col.reduce(function (a, n) { return a + n.v * k; }, 0) + gap * (col.length - 1);
        var y = (H - used) / 2;
        col.forEach(function (n) {
          n.x = g.layers > 1 ? (li / (g.layers - 1)) * (W - nw) : (W - nw) / 2;
          n.y = y;
          n.h = Math.max(2, n.v * k);
          y += n.h + gap;
        });
      });
      // Stack each node's flows in the order of the nodes they connect to.
      g.nodes.forEach(function (n) {
        var oy = n.y;
        n.outs.slice().sort(function (a, b) { return a.t.y - b.t.y; }).forEach(function (l) { l.th = l.v * k; l.sy = oy + l.th / 2; oy += l.th; });
        var iy = n.y;
        n.ins.slice().sort(function (a, b) { return a.s.y - b.s.y; }).forEach(function (l) { l.ty = iy + l.v * k / 2; iy += l.v * k; });
      });

      var defs = '', links = '', streams = '', nodes = '', labels = '';
      g.links.forEach(function (l, i) {
        var x0 = l.s.x + nw, x1 = l.t.x, mx = (x0 + x1) / 2;
        var path = 'M' + x0 + ' ' + l.sy + ' C' + mx + ' ' + l.sy + ' ' + mx + ' ' + l.ty + ' ' + x1 + ' ' + l.ty;
        defs += '<linearGradient id="' + c.id + '-' + i + '" gradientUnits="userSpaceOnUse" x1="' + x0 + '" x2="' + x1 + '" y1="0" y2="0">' +
          '<stop offset="0" stop-color="' + l.s.color + '"/><stop offset="1" stop-color="' + l.t.color + '"/></linearGradient>';
        links += '<path class="skf-link" d="' + path + '" fill="none" stroke="url(#' + c.id + '-' + i + ')" stroke-opacity=".42" stroke-width="' + Math.max(1, l.th - 1).toFixed(2) + '"/>';
        if (d.stream !== 'false') {
          streams += '<path class="skf-stream" d="' + path + '" stroke-width="' + Math.max(1.5, Math.min(4, l.th * 0.22)).toFixed(2) + '" stroke-opacity=".8" style="animation-duration:' + (0.8 + (i % 3) * 0.15) + 's"/>';
        }
      });
      g.nodes.forEach(function (n) {
        nodes += '<rect class="skf-node" x="' + n.x + '" y="' + n.y + '" width="' + nw + '" height="' + n.h + '" rx="3" fill="' + n.color + '"/>';
        var last = n.layer === g.layers - 1 && g.layers > 1;
        labels += '<text class="skf-label" x="' + (last ? n.x - 6 : n.x + nw + 6) + '" y="' + (n.y + n.h / 2) + '" dy=".35em" text-anchor="' + (last ? 'end' : 'start') + '">' +
          esc(n.name) + (d.values === 'false' ? '' : ' <tspan>' + esc(c.pre) + n.v.toLocaleString() + esc(c.suf) + '</tspan>') + '</text>';
      });
      c.el.innerHTML = '<svg viewBox="0 0 ' + W + ' ' + H + '" aria-hidden="true"><defs>' + defs + '</defs>' +
        '<g>' + links + '</g><g>' + streams + '</g><g>' + nodes + '</g><g>' + labels + '</g></svg>';
      c.links = Array.prototype.slice.call(c.el.querySelectorAll('.skf-link'));
      c.streams = Array.prototype.slice.call(c.el.querySelectorAll('.skf-stream'));
      c.nodes = Array.prototype.slice.call(c.el.querySelectorAll('.skf-node'));
      c.labels = Array.prototype.slice.call(c.el.querySelectorAll('.skf-label'));
    }

    function show(c) {
      anime.utils.set(c.nodes.concat(c.labels), { opacity: 1, scaleY: 1 });
      anime.utils.set(c.streams, { opacity: 1 });
    }

    function hide(c) {
      anime.utils.set(c.nodes, { scaleY: 0 });
      anime.utils.set(c.labels.concat(c.streams), { opacity: 0 });
      c.links.forEach(function (l) { anime.utils.set(anime.svg.createDrawable(l), { draw: '0 0' }); });
    }

    function play(c) {
      var d = c.el.dataset, g = c.g;
      var duration = +d.duration || DEFAULTS.duration;
      var col = Math.max(300, duration * 0.6); // time between columns
      var tl = anime.createTimeline();
      c.busy = true;
      hide(c);
      tl.add(c.nodes, {
        scaleY: [0, 1], duration: 500, ease: 'outBack(1.6)',
        delay: function (el, i) { return g.nodes[i].layer * col + (i % 4) * 40; }
      }, 0);
      tl.add(c.labels, {
        opacity: [0, 1], duration: 400,
        delay: function (el, i) { return g.nodes[i].layer * col + 200; }
      }, 0);
      c.links.forEach(function (l, i) {
        tl.add(anime.svg.createDrawable(l), { draw: ['0 0', '0 1'], duration: duration, ease: 'inOut(2)' }, g.links[i].s.layer * col + 250 + (i % 5) * 50);
      });
      tl.add(c.streams, { opacity: [0, 1], duration: 600, delay: anime.stagger(40) }, '-=300');
      tl.then(function () {
        c.busy = false;
        c.played = true;
        if (d.loop === undefined || d.loop === 'false') return;
        setTimeout(function () {
          c.busy = true;
          anime.animate(c.streams.concat(c.labels), { opacity: 0, duration: 300 });
          c.links.forEach(function (l) { anime.animate(anime.svg.createDrawable(l), { draw: '1 1', duration: 500, ease: 'in(2)' }); });
          anime.animate(c.nodes, { scaleY: 0, duration: 400, delay: 300, ease: 'in(2)', onComplete: function () { play(c); } });
        }, DEFAULTS.hold);
      });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._skf;
          // Stop the dots while off screen.
          c.el.style.setProperty('--skf-run', e.isIntersecting ? 'running' : 'paused');
          if (!e.isIntersecting || c.started) return;
          c.started = true;
          play(c);
        });
      }, { threshold: 0.3 });
      var ro = new ResizeObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._skf;
          if (!c || (c.w === c.el.clientWidth && c.h === c.el.clientHeight)) return;
          build(c);
          if (c.played || reduced()) show(c);
          else if (!c.busy) hide(c);
          else { c.played = true; show(c); }
        });
      });
      document.querySelectorAll('[data-sankey-flow]').forEach(function (el) {
        if (el._skf) return;
        var c = setup(el);
        ro.observe(el);
        if (reduced()) {
          c.played = true;
          c.streams.forEach(function (s) { s.style.animation = 'none'; });
          show(c);
          return;
        }
        hide(c);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-sankey-flow="Ads > Sign-up: 30, Ads > Left: 22,
     Search > Sign-up: 44, Search > Left: 12"
     style="height: 260px"></div>