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
- Paste the snippet just before
</head>. - Add
data-streamgraphto an element with a height. On its own it makes up an endless flow. - 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-labels | Comma-separated layer names, shown as a legend. |
data-layers | How many made-up layers. Default 5. |
data-colors | Comma-separated colours, one per layer. Default purple, pink, blue… |
data-speed | How fast the made-up flow moves. Default 1. |
data-legend | false hides the legend. |
data-duration | Time for the streams to swell in. Default 1400 ms. |
data-loop | With data-values: drain away and sweep in again. |
The flow stops while it’s off screen. With reduced motion it shows a still frame.
<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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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><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>