← Charts

Streamgraph

A streamgraph: stacked layers flowing either side of a centre line. The streams swell in from nothing, then keep flowing along, each one thickening and thinning as its value rises and falls. Point at a stream to pick it out.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-streamgraph to an element with a height. On its own it makes up an endless flow.
  3. For your own data add data-values: one comma-separated list per layer, the lists separated by |. It then sweeps in from the left and stays put.

Options

Add any of these attributes to the same element.

data-labelsComma-separated layer names, shown as a legend.
data-layersHow many made-up layers. Default 5.
data-colorsComma-separated colours, one per layer. Default purple, pink, blue…
data-speedHow fast the made-up flow moves. Default 1.
data-legendfalse hides the legend.
data-durationTime for the streams to swell in. Default 1400 ms.
data-loopWith data-values: drain away and sweep in again.

The flow stops while it’s off screen. With reduced motion it shows a still frame.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Streamgraph: stacked layers of a stream chart, centred on a flowing
    // baseline. The streams swell in from nothing, then keep flowing along,
    // each layer thickening and thinning as its value rises and falls.
    // Usage: <div data-streamgraph style="height:240px"></div>
    // On its own it makes up an endless flow. For your own data, give
    // data-values with one comma list per layer, separated by "|":
    //   <div data-streamgraph data-values="4,6,9,7|3,3,5,8|2,4,3,2"
    //        data-labels="Rock,Pop,Jazz" style="height:240px"></div>
    // Optional: data-labels (layer names, shown as a legend), data-layers
    // (made-up layers, default 5), data-colors="#a855f7,#ec4899,…",
    // data-speed (flow speed, default 1), data-legend="false",
    // data-duration (ms for the streams to swell in, default 1400),
    // data-loop (your own data: flow out and in again).
    var DEFAULTS = { layers: 5, speed: 1, duration: 1400, hold: 3000, window: 22 };
    var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1', '#f43f5e', '#14b8a6'];
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.sgr{display:flex;flex-direction:column;gap:8px;font-size:12px}' +
      '.sgr-plot{position:relative;flex:1;min-height:0;overflow:hidden}' +
      '.sgr-plot svg{position:absolute;inset:0;width:100%;height:100%}' +
      '.sgr-mid{stroke:currentColor;stroke-opacity:.12;stroke-dasharray:3 5}' +
      '.sgr-layer{stroke:#fff;stroke-width:1;stroke-opacity:.9;transition:opacity .25s}' +
      '.sgr-plot svg:hover .sgr-layer{opacity:.35}' +
      '.sgr-plot svg .sgr-layer:hover,.sgr-plot svg .sgr-layer.sgr-on{opacity:1}' +
      '.sgr-legend{display:flex;flex-wrap:wrap;justify-content:center;gap:4px 14px;font-weight:600;opacity:.8}' +
      '.sgr-legend span{display:inline-flex;align-items:center;gap:6px}' +
      '.sgr-legend i{width:10px;height:10px;border-radius:3px;background:var(--sgr-c)}';
    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]; }); }

    // A smooth curve through points (Catmull–Rom as cubic Béziers).
    function smooth(pts, move) {
      var s = (move ? 'M' : 'L') + pts[0][0].toFixed(1) + ' ' + pts[0][1].toFixed(1);
      for (var i = 0; i < pts.length - 1; i++) {
        var p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;
        s += ' C' + (p1[0] + (p2[0] - p0[0]) / 6).toFixed(1) + ' ' + (p1[1] + (p2[1] - p0[1]) / 6).toFixed(1) +
          ' ' + (p2[0] - (p3[0] - p1[0]) / 6).toFixed(1) + ' ' + (p2[1] - (p3[1] - p1[1]) / 6).toFixed(1) +
          ' ' + p2[0].toFixed(1) + ' ' + p2[1].toFixed(1);
      }
      return s;
    }

    function setup(el) {
      var d = el.dataset;
      var labels = d.labels ? d.labels.split(',').map(function (s) { return s.trim(); }) : [];
      var data = d.values ? d.values.split('|').map(function (row) {
        return row.split(/[\s,]+/).filter(function (x) { return x !== ''; }).map(function (x) { return Math.max(0, +x || 0); });
      }).filter(function (r) { return r.length > 1; }) : null;
      if (data && !data.length) data = null;
      var n = data ? data.length : Math.max(2, +d.layers || labels.length || DEFAULTS.layers);
      var colors = d.colors ? d.colors.split(',') : PALETTE;
      var c = { el: el, data: data, n: n, t: 0, grow: 0, reveal: data ? 0 : 1, running: false, visible: false };
      c.colors = []; for (var i = 0; i < n; i++) c.colors.push(colors[i % colors.length]);
      c.labels = labels;
      // Each made-up layer gets its own size, rhythm and swell.
      var seed = 5;
      function rnd() { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; }
      c.waves = c.colors.map(function () {
        return { b: 0.6 + rnd() * 0.8, f1: 0.7 + rnd() * 0.6, f2: 1.6 + rnd(), f3: 0.25 + rnd() * 0.2, p1: rnd() * 6.3, p2: rnd() * 6.3, p3: rnd() * 6.3 };
      });
      el.classList.add('sgr');
      el.setAttribute('role', 'img');
      if (data) {
        el.setAttribute('aria-label', 'Streamgraph. ' + data.map(function (r, i) {
          return (labels[i] || 'Layer ' + (i + 1)) + ': ' + r.join(', ');
        }).join('. ') + '.');
      } else {
        el.setAttribute('aria-label', 'Streamgraph of ' + n + ' layers' + (labels.length ? ' (' + labels.join(', ') + ')' : '') + ' flowing over time.');
      }
      var legend = d.legend !== 'false' && labels.length;
      el.innerHTML = '<div class="sgr-plot" aria-hidden="true"></div>' + (legend ? '<div class="sgr-legend" aria-hidden="true">' +
        labels.slice(0, n).map(function (l, i) { return '<span style="--sgr-c:' + esc(c.colors[i]) + '"><i></i>' + esc(l) + '</span>'; }).join('') + '</div>' : '');
      c.plot = el.querySelector('.sgr-plot');
      c.legendItems = Array.prototype.slice.call(el.querySelectorAll('.sgr-legend span'));
      if (data) {
        c.len = Math.max.apply(null, data.map(function (r) { return r.length; }));
        var mx = 0;
        for (var j = 0; j < c.len; j++) {
          var s = 0;
          data.forEach(function (r) { s += r[j] || 0; });
          mx = Math.max(mx, s);
        }
        c.max = mx || 1;
      } else {
        c.max = c.waves.reduce(function (a, w) { return a + w.b * 1.0; }, 0);
      }
      el._sgr = c;
      layout(c);
      return c;
    }

    function layout(c) {
      var W = c.plot.clientWidth, H = c.plot.clientHeight;
      if (!W || !H) return;
      c.W = W; c.H = H;
      var svg = document.createElementNS(NS, 'svg');
      svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
      var html = '<line class="sgr-mid" x1="0" x2="' + W + '" y1="' + H / 2 + '" y2="' + H / 2 + '"/>';
      c.colors.forEach(function (col, i) { html += '<path class="sgr-layer" fill="' + col + '" fill-opacity=".88"/>'; });
      svg.innerHTML = html;
      c.plot.innerHTML = '';
      c.plot.appendChild(svg);
      c.paths = Array.prototype.slice.call(svg.querySelectorAll('.sgr-layer'));
      c.paths.forEach(function (p, i) {
        function on() { if (c.legendItems[i]) c.legendItems[i].style.opacity = 1; c.legendItems.forEach(function (l, j) { if (j !== i) l.style.opacity = 0.4; }); }
        function off() { c.legendItems.forEach(function (l) { l.style.opacity = ''; }); }
        p.addEventListener('pointerenter', on);
        p.addEventListener('pointerleave', off);
      });
      render(c);
    }

    function value(c, i, x) {
      if (c.data) {
        var r = c.data[i] || [];
        return r[Math.round(x)] || 0;
      }
      var w = c.waves[i], t = x;
      var bump = Math.pow(Math.max(0, Math.sin(t * w.f3 + w.p3)), 4);
      return w.b * (0.5 + 0.22 * Math.sin(t * w.f1 * 0.5 + w.p1) + 0.14 * Math.sin(t * w.f2 * 0.5 + w.p2) + 0.4 * bump);
    }

    function render(c) {
      if (!c.paths) return;
      var W = c.W, H = c.H;
      var cols = c.data ? c.len : DEFAULTS.window + 1;
      var xs = [], stacks = [];
      // Made-up data: sample a continuous flow so it scrolls smoothly leftwards.
      var shift = c.data ? 0 : c.t % 1, base = c.data ? 0 : Math.floor(c.t);
      for (var j = 0; j <= cols; j++) {
        var col = c.data ? Math.min(j, cols - 1) : j;
        if (c.data && j === cols) break;
        xs.push(c.data ? (cols > 1 ? j / (cols - 1) * W : W / 2) : (j - shift) / (cols - 1) * W);
        var vals = [], sum = 0;
        for (var i = 0; i < c.n; i++) { var v = value(c, i, base + col); vals.push(v); sum += v; }
        stacks.push({ vals: vals, sum: sum });
      }
      var scale = (H * 0.92) / c.max * c.grow;
      var clip = c.reveal * W;
      c.paths.forEach(function (p, i) {
        var top = [], bot = [];
        stacks.forEach(function (s, j) {
          var x = xs[j];
          // Streams swell up from the centre line as they're revealed.
          var local = c.data ? Math.max(0, Math.min(1, (clip - x) / (W * 0.25) + 0.15)) : 1;
          var y0 = H / 2 - s.sum * scale * local / 2;
          for (var k = 0; k < i; k++) y0 += s.vals[k] * scale * local;
          top.push([x, y0]);
          bot.push([x, y0 + s.vals[i] * scale * local]);
        });
        p.setAttribute('d', smooth(top, true) + ' ' + smooth(bot.reverse(), false) + 'Z');
      });
    }

    function loop(c) {
      if (c.running) return;
      c.running = true;
      var last = performance.now();
      function frame(now) {
        if (!c.visible) { c.running = false; return; }
        var dt = Math.min(64, now - last);
        last = now;
        c.t += dt / 1000 * 1.1 * (+c.el.dataset.speed || DEFAULTS.speed);
        render(c);
        requestAnimationFrame(frame);
      }
      requestAnimationFrame(frame);
    }

    function play(c) {
      var d = c.el.dataset;
      var dur = +d.duration || DEFAULTS.duration;
      c.grow = c.data ? 1 : 0;
      c.reveal = c.data ? 0 : 1;
      render(c);
      var a = anime.animate(c, c.data
        ? { reveal: 1.4, duration: dur, ease: 'inOut(2)', onUpdate: function () { render(c); } }
        : { grow: 1, duration: dur, ease: 'out(3)' });
      if (c.data && d.loop !== undefined && d.loop !== 'false') {
        a.then(function () {
          setTimeout(function () {
            anime.animate(c, {
              grow: 0, duration: 600, ease: 'in(2)', onUpdate: function () { render(c); },
              onComplete: function () { setTimeout(function () { play(c); }, 250); }
            });
          }, DEFAULTS.hold);
        });
      }
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._sgr;
          c.visible = e.isIntersecting;
          if (!e.isIntersecting) return;
          if (!c.played) { c.played = true; play(c); }
          if (!c.data) loop(c);
        });
      }, { threshold: 0.2 });
      var ro = new ResizeObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._sgr;
          if (c && (c.plot.clientWidth !== c.W || c.plot.clientHeight !== c.H)) layout(c);
        });
      });
      document.querySelectorAll('[data-streamgraph]').forEach(function (el) {
        if (el._sgr) return;
        var c = setup(el);
        ro.observe(el);
        if (reduced()) { c.grow = 1; c.reveal = 1.4; c.t = 3; render(c); return; }
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-streamgraph style="height: 240px"></div>

<!-- or, your own data: -->
<div data-streamgraph data-labels="Pop,Rock,Jazz" style="height: 240px"
     data-values="4,6,9,7,5|3,3,5,8,6|2,4,3,2,4"></div>