← Charts

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

  1. Paste the snippet just before </head>.
  2. Add data-waterfall-chart to a container with a height, with one child per bar: its text is the label and data-value the change (negative for a fall).
  3. Add data-total to a bar to show the running total there. Give it a data-value too 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-colorBar colours. Default green, red and purple.
data-prefix / data-suffixText around the values, e.g. $ and k.
data-valuesfalse hides the values on the bars.
data-stepTime for each bar. Default 420 ms.
data-loopTake the bars down and step through again.
1 · Snippet
<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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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>
2 · Your HTML
<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>