Waterfall Chart
A waterfall chart. Each bar grows up or down from the level the last one left off at, green for a rise and red for a fall, a dashed line carries the running total across to the next bar, and total bars rise from the axis to show where things stand.
StartSalesSubsFeesStaffRentTaxEnd
How to use it
- Paste the snippet just before
</head>. - Add
data-waterfall-chartto a container with a height, with one child per bar: its text is the label anddata-valuethe change (negative for a fall). - Add
data-totalto a bar to show the running total there. Give it adata-valuetoo to set the total, e.g. for the starting balance.
Options
Add any of these attributes to the same element.
data-up / data-down / data-total-color | Bar colours. Default green, red and purple. |
data-prefix / data-suffix | Text around the values, e.g. $ and k. |
data-values | false hides the values on the bars. |
data-step | Time for each bar. Default 420 ms. |
data-loop | Take the bars down and step through again. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Waterfall Chart: bars step up and down from a running total, one after
// another. Each rise or fall grows out of the level the last bar left off
// at, a dashed connector carries the level across to the next bar, and
// total bars rise from the axis to show where things stand.
// Usage:
// <div data-waterfall-chart style="height:260px">
// <span data-total data-value="400">Start</span>
// <span data-value="260">Sales</span>
// <span data-value="-80">Refunds</span>
// <span data-total>End</span>
// </div>
// Each child is one bar: its text is the label, data-value the change
// (negative for a fall). Add data-total for a bar showing the running total
// so far; give it a data-value too to set the total there instead.
// Optional: data-up / data-down / data-total-color (bar colours, defaults
// green, red and purple), data-prefix / data-suffix (around the values),
// data-values="false" (no values on the bars), data-step (ms per bar,
// default 420), data-loop (take the bars down and step through again).
var DEFAULTS = { step: 420, hold: 2800, up: '#10b981', down: '#f43f5e', total: '#a855f7' };
var css =
'.wfc{display:flex;flex-direction:column;gap:8px;font-variant-numeric:tabular-nums}' +
'.wfc-plot{position:relative;flex:1;min-height:0;display:flex;' +
'background:linear-gradient(currentColor,currentColor) 0 var(--wfc-zero)/100% 1px no-repeat}' +
'.wfc-plot::before{content:"";position:absolute;inset:0;pointer-events:none;opacity:.07;' +
'background:repeating-linear-gradient(to top,currentColor 0 1px,transparent 1px 25%)}' +
'.wfc-col{position:relative;flex:1;height:100%}' +
'.wfc-bar{position:absolute;left:16%;right:16%;height:0;border-radius:4px;background:var(--wfc-c)}' +
'.wfc-link{position:absolute;left:84%;width:32%;height:0;border-top:1.5px dashed currentColor;opacity:0;transform-origin:0 0}' +
'.wfc-val{position:absolute;left:-30%;right:-30%;text-align:center;font-size:11px;font-weight:700;line-height:14px;white-space:nowrap;color:var(--wfc-c);opacity:0}' +
'.wfc-labels{display:flex;font-size:11px;opacity:.65}' +
'.wfc-labels span{flex:1;min-width:0;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding:0 1px}';
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]; }); }
function setup(el) {
var d = el.dataset;
var pre = d.prefix || '', suf = d.suffix || '';
var run = 0;
var bars = Array.prototype.slice.call(el.children).map(function (b) {
var total = b.hasAttribute('data-total');
var has = b.getAttribute('data-value') !== null && b.getAttribute('data-value') !== '';
var v = +b.getAttribute('data-value') || 0;
var from, to;
if (total) { if (has) run = v; from = 0; to = run; }
else { from = run; run += v; to = run; }
return { label: b.textContent.trim(), total: total, change: total ? to : v, from: from, to: to, level: run };
});
var lo = Math.min.apply(null, [0].concat(bars.map(function (b) { return Math.min(b.from, b.to); })));
var hi = Math.max.apply(null, [0].concat(bars.map(function (b) { return Math.max(b.from, b.to); })));
var span = hi - lo || 1;
// Positions as % from the top, with room above and below for the values.
// (When nothing goes below zero the axis sits at the bottom, on the last grid line.)
var room = lo < 0 ? 82 : 91;
var y = function (v) { return 9 + (1 - (v - lo) / span) * room; };
function fmt(v, sign) {
var s = pre + Math.abs(Math.round(v)).toLocaleString() + suf;
return v < 0 ? '−' + s : sign && v > 0 ? '+' + s : s;
}
el.classList.add('wfc');
el.setAttribute('role', 'img');
el.setAttribute('aria-label', 'Waterfall chart. ' + bars.map(function (b) {
return b.label + ': ' + (b.total ? 'total ' + fmt(b.to) : fmt(b.change, true) + ', running total ' + fmt(b.to));
}).join('. '));
var html = '<div class="wfc-plot" aria-hidden="true" style="--wfc-zero:' + y(0) + '%">';
bars.forEach(function (b, i) {
var c = b.total ? (d.totalColor || DEFAULTS.total) : b.change < 0 ? (d.down || DEFAULTS.down) : (d.up || DEFAULTS.up);
html += '<div class="wfc-col" style="--wfc-c:' + esc(c) + '"><div class="wfc-bar"></div>' +
(d.values === 'false' ? '' : '<span class="wfc-val"></span>') +
(i < bars.length - 1 ? '<span class="wfc-link" style="top:' + y(b.to) + '%"></span>' : '') + '</div>';
});
html += '</div><div class="wfc-labels" aria-hidden="true">' + bars.map(function (b) { return '<span>' + esc(b.label) + '</span>'; }).join('') + '</div>';
el.innerHTML = html;
var cols = Array.prototype.slice.call(el.querySelectorAll('.wfc-col'));
var c = { el: el, bars: bars, y: y, fmt: fmt };
c.barEls = cols.map(function (col) { return col.querySelector('.wfc-bar'); });
c.vals = cols.map(function (col) { return col.querySelector('.wfc-val'); });
c.links = cols.map(function (col) { return col.querySelector('.wfc-link'); });
el._wfc = c;
return c;
}
// Bar i at k (0–1): grown from where it starts (its "from" level) towards its end.
function grow(c, i, k) {
var b = c.bars[i];
var a = c.y(b.from), e = c.y(b.from + (b.to - b.from) * k);
var top = Math.min(a, e), h = Math.abs(e - a);
c.barEls[i].style.top = top + '%';
c.barEls[i].style.height = 'max(' + h + '%, ' + (k > 0 ? 2 : 0) + 'px)';
var v = c.vals[i];
if (v) {
var down = b.to < b.from;
v.style.top = down ? 'calc(' + (top + h) + '% + 3px)' : 'calc(' + top + '% - 17px)';
v.textContent = c.fmt((b.total ? b.to : b.change) * Math.min(1, k), !b.total);
}
}
function finish(c) {
c.bars.forEach(function (b, i) {
grow(c, i, 1);
if (c.vals[i]) c.vals[i].style.opacity = 1;
if (c.links[i]) c.links[i].style.opacity = 0.45;
});
}
function play(c) {
var d = c.el.dataset;
var step = +d.step || DEFAULTS.step;
var tl = anime.createTimeline();
c.bars.forEach(function (b, i) {
grow(c, i, 0);
var at = i * step, st = { k: 0 };
if (c.vals[i]) tl.add(c.vals[i], { opacity: [0, 1], duration: 200 }, at);
tl.add(st, {
k: 1, duration: step * 1.3, ease: b.total ? 'out(3)' : 'outBack(1.3)',
onUpdate: function () { grow(c, i, st.k); }
}, at);
if (c.links[i]) tl.add(c.links[i], { opacity: [0, 0.45], scaleX: [0, 1], duration: step * 0.6, ease: 'out(2)' }, at + step * 0.8);
});
if (d.loop !== undefined && d.loop !== 'false') {
tl.then(function () {
setTimeout(function () {
var fade = c.links.concat(c.vals).filter(Boolean);
anime.animate(fade, { opacity: 0, duration: 300 });
var st = { k: 1 };
anime.animate(st, {
k: 0, duration: 500, ease: 'in(2)',
onUpdate: function () { c.bars.forEach(function (b, i) { grow(c, i, st.k); }); },
onComplete: function () { setTimeout(function () { play(c); }, 250); }
});
}, DEFAULTS.hold);
});
}
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
play(e.target._wfc);
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-waterfall-chart]').forEach(function (el) {
if (el._wfc) return;
var c = setup(el);
if (reduced()) { finish(c); return; }
c.bars.forEach(function (b, i) { grow(c, i, 0); });
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-waterfall-chart data-prefix="$" data-suffix="k" style="height: 260px">
<span data-total data-value="400">Start</span>
<span data-value="260">Sales</span>
<span data-value="-80">Refunds</span>
<span data-value="-140">Costs</span>
<span data-total>End</span>
</div>