← Charts

Histogram with Curve

A histogram whose bars rise one after another, then a smooth bell curve fitted to the data draws itself over them and a dashed line drops in at the average. Give it your raw numbers and it does the binning for you.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-histogram-with-curve="…values…" to an element with a height, listing your raw numbers. Leave it empty to plot a made-up sample.

Options

Add any of these attributes to the same element.

data-binsRoughly how many bars. Default 14.
data-colorBar colour. Default #a855f7.
data-curve-colorCurve colour. Default #ec4899.
data-curvefalse shows just the bars.
data-meanfalse hides the average line.
data-suffixText after the axis numbers, e.g. ms.
data-countSize of the made-up sample. Default 400.
data-durationTime for all the bars to rise. Default 1200 ms.
data-loopDrop the bars and draw again. A made-up sample is redrawn fresh each time.

The curve is a normal distribution with the data’s own average and spread, scaled to the bars.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Histogram with Curve: the bars of a histogram rise one after another,
    // then a smooth bell curve fitted to the data draws itself over them and
    // a dashed line drops in at the average.
    // Usage: <div data-histogram-with-curve="12,15,15,18,…" style="height:240px"></div>
    // The attribute holds the raw values (commas or spaces). Leave it empty to
    // plot a made-up sample instead (data-count sets its size, default 400;
    // with data-loop each round draws a fresh sample).
    // Optional: data-bins (how many bars, default 14), data-color (bars,
    // default #a855f7), data-curve-color (default #ec4899), data-curve="false"
    // (bars only), data-mean="false" (no average line), data-suffix (after
    // the axis numbers, e.g. "ms"), data-duration (ms for the bars to rise,
    // default 1200), data-loop (drop the bars and draw again).
    var DEFAULTS = { bins: 14, count: 400, duration: 1200, hold: 2600, color: '#a855f7', curve: '#ec4899' };
    var NS = 'http://www.w3.org/2000/svg';

    var css =
      '.hwc{position:relative;font-size:11px;font-variant-numeric:tabular-nums}' +
      '.hwc svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}' +
      '.hwc-axis{stroke:currentColor;stroke-opacity:.3}' +
      '.hwc-grid{stroke:currentColor;stroke-opacity:.08}' +
      '.hwc-tick{fill:currentColor;fill-opacity:.55}' +
      '.hwc-curve{fill:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round}' +
      '.hwc-under{stroke:none}' +
      '.hwc-mean{stroke:currentColor;stroke-opacity:.55;stroke-width:1.5;stroke-dasharray:4 4}' +
      '.hwc-mlabel{fill:currentColor;font-weight:700;paint-order:stroke;stroke:#fff;stroke-opacity:.8;stroke-width:3px;stroke-linejoin:round}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function mk(tag, attrs, parent) {
      var n = document.createElementNS(NS, tag);
      for (var k in attrs) n.setAttribute(k, attrs[k]);
      if (parent) parent.appendChild(n);
      return n;
    }

    // A believable sample: a bell shape with a slight lean.
    function sample(n, seed) {
      function rnd() { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; }
      var out = [];
      for (var i = 0; i < n; i++) {
        var g = Math.sqrt(-2 * Math.log(rnd() || 1e-9)) * Math.cos(2 * Math.PI * rnd());
        out.push(Math.round((50 + g * 12 + (g > 0 ? g * 1.5 : 0)) * 10) / 10);
      }
      return out;
    }

    function niceStep(raw) {
      var p = Math.pow(10, Math.floor(Math.log10(raw || 1)));
      // The round step nearest to the raw width.
      return [1, 2, 2.5, 5, 10].map(function (m) { return m * p; }).sort(function (a, b) {
        return Math.abs(Math.log(a / raw)) - Math.abs(Math.log(b / raw));
      })[0];
    }

    // Bin the values and fit a normal curve.
    function analyse(c) {
      var v = c.values, bins = c.binCount;
      var lo = Math.min.apply(null, v), hi = Math.max.apply(null, v);
      if (hi === lo) { hi = lo + 1; }
      var w = niceStep((hi - lo) / bins);
      var start = Math.floor(lo / w) * w;
      var n = Math.max(1, Math.ceil((hi - start) / w + 1e-9));
      if (start + n * w <= hi) n++;
      var counts = [];
      for (var i = 0; i < n; i++) counts.push(0);
      var sum = 0;
      v.forEach(function (x) { counts[Math.min(n - 1, Math.floor((x - start) / w))]++; sum += x; });
      var mean = sum / v.length, sq = 0;
      v.forEach(function (x) { sq += (x - mean) * (x - mean); });
      var sd = Math.sqrt(sq / v.length) || w;
      // Room either side so the curve's tails can taper off.
      var pad = Math.max(1, Math.round(n * 0.08));
      c.h = { start: start, w: w, n: n, counts: counts, mean: mean, sd: sd, x0: start - pad * w, x1: start + (n + pad) * w };
      var peak = v.length * w / (sd * Math.sqrt(2 * Math.PI));
      c.h.top = Math.max(Math.max.apply(null, counts), peak) * 1.12;
    }

    function setup(el) {
      var d = el.dataset;
      var raw = (el.getAttribute('data-histogram-with-curve') || '').split(/[\s,;]+/).filter(function (s) { return s !== '' && !isNaN(+s); }).map(Number);
      var c = { el: el, made: raw.length < 2, seed: 11, st: { bars: [], curve: 0, mean: 0 } };
      c.count = +d.count || DEFAULTS.count;
      c.values = c.made ? sample(c.count, c.seed) : raw;
      c.binCount = +d.bins || DEFAULTS.bins;
      el.classList.add('hwc');
      el.setAttribute('role', 'img');
      el._hwc = c;
      analyse(c);
      label(c);
      layout(c);
      return c;
    }

    function label(c) {
      var h = c.h, suf = c.el.dataset.suffix || '';
      c.el.setAttribute('aria-label', 'Histogram of ' + c.values.length + ' values. Average ' + round(h.mean) + suf +
        ', spread (standard deviation) ' + round(h.sd) + suf + '.');
    }
    function round(x) { return Math.abs(x) >= 100 ? Math.round(x) : Math.round(x * 10) / 10; }

    function layout(c) {
      var d = c.el.dataset, h = c.h;
      var W = c.el.clientWidth, H = c.el.clientHeight;
      if (!W || !H) return;
      c.W = W; c.H = H;
      var bot = H - 18, topY = 6;
      c.sx = function (x) { return (x - h.x0) / (h.x1 - h.x0) * W; };
      c.sy = function (y) { return bot - y / h.top * (bot - topY); };
      c.bot = bot;
      var svg = mk('svg', { viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' });
      for (var g = 1; g <= 3; g++) mk('line', { class: 'hwc-grid', x1: 0, x2: W, y1: bot - g * (bot - topY) / 3.4, y2: bot - g * (bot - topY) / 3.4 }, svg);
      var color = d.color || DEFAULTS.color, curveColor = d.curveColor || DEFAULTS.curve;
      var bw = c.sx(h.w) - c.sx(0);
      var gap = Math.min(2, bw * 0.15);
      c.rects = h.counts.map(function (n, i) {
        return mk('rect', { x: c.sx(h.start + i * h.w) + gap / 2, width: Math.max(1, bw - gap), y: bot, height: 0, rx: Math.min(3, bw / 4), fill: color, 'fill-opacity': .8 }, svg);
      });
      mk('line', { class: 'hwc-axis', x1: 0, x2: W, y1: bot + .5, y2: bot + .5 }, svg);
      // Axis numbers: a round step, as many as fit.
      var suf = d.suffix || '';
      var every = Math.max(1, Math.ceil(46 / (bw * 2)));
      var step = h.w * 2 * every;
      for (var t = Math.ceil(h.start / step) * step; t <= h.start + h.n * h.w + 1e-9; t += step) {
        var tx = c.sx(t);
        if (tx < 10 || tx > W - 10) continue;
        mk('text', { class: 'hwc-tick', x: tx, y: H - 3, 'text-anchor': 'middle' }, svg).textContent = round(t) + suf;
      }
      c.curvePath = null;
      if (d.curve !== 'false') {
        var pts = [], N = 80;
        for (var k = 0; k <= N; k++) {
          var x = h.x0 + (h.x1 - h.x0) * k / N;
          var y = c.values.length * h.w / (h.sd * Math.sqrt(2 * Math.PI)) * Math.exp(-0.5 * Math.pow((x - h.mean) / h.sd, 2));
          pts.push(c.sx(x).toFixed(1) + ' ' + c.sy(y).toFixed(1));
        }
        var dPath = 'M' + pts.join(' L');
        c.under = mk('path', { class: 'hwc-under', d: dPath + ' L' + W + ' ' + bot + ' L0 ' + bot + 'Z', fill: curveColor, 'fill-opacity': 0 }, svg);
        c.curvePath = mk('path', { class: 'hwc-curve', d: dPath, stroke: curveColor }, svg);
        c.curveLen = c.curvePath.getTotalLength ? c.curvePath.getTotalLength() : 1000;
        c.curvePath.style.strokeDasharray = c.curveLen + ' ' + c.curveLen;
      }
      c.meanEls = null;
      if (d.mean !== 'false') {
        var mx = c.sx(h.mean);
        c.meanTop = topY + 14;
        var ml = mk('line', { class: 'hwc-mean', x1: mx, x2: mx, y1: c.meanTop, y2: bot }, svg);
        var anchor = mx < 50 ? 'start' : mx > W - 50 ? 'end' : 'middle';
        var mt = mk('text', { class: 'hwc-mlabel', x: mx, y: topY + 8, 'text-anchor': anchor }, svg);
        mt.textContent = 'Avg ' + round(h.mean) + suf;
        c.meanEls = [ml, mt];
      }
      c.el.innerHTML = '';
      c.el.appendChild(svg);
      render(c);
    }

    function render(c) {
      if (!c.rects) return;
      var h = c.h, st = c.st;
      c.rects.forEach(function (r, i) {
        var k = st.bars[i] || 0;
        var y = c.sy(h.counts[i] * k);
        r.setAttribute('y', Math.min(y, c.bot));
        r.setAttribute('height', Math.max(0, c.bot - y));
      });
      if (c.curvePath) {
        c.curvePath.style.strokeDashoffset = c.curveLen * (1 - st.curve);
        c.curvePath.style.opacity = st.curve > 0 ? 1 : 0;
        c.under.setAttribute('fill-opacity', 0.1 * Math.max(0, st.curve * 2 - 1));
      }
      if (c.meanEls) {
        c.meanEls[0].style.opacity = st.mean;
        c.meanEls[1].style.opacity = st.mean;
        c.meanEls[0].setAttribute('y1', c.bot - (c.bot - c.meanTop) * st.mean);
      }
    }

    function finish(c) {
      c.st.bars = c.h.counts.map(function () { return 1; });
      c.st.curve = 1; c.st.mean = 1;
      render(c);
    }

    function play(c) {
      var d = c.el.dataset;
      var dur = +d.duration || DEFAULTS.duration;
      var n = c.h.n;
      c.st.bars = c.h.counts.map(function () { return 0; });
      c.st.curve = 0; c.st.mean = 0;
      render(c);
      var tl = anime.createTimeline({ onUpdate: function () { render(c); } });
      var each = dur * 0.55, gap = n > 1 ? (dur - each) / (n - 1) : 0;
      var bars = c.h.counts.map(function () { return { k: 0 }; });
      bars.forEach(function (b, i) {
        tl.add(b, { k: 1, duration: each, ease: 'outBack(1.4)', onUpdate: function () { c.st.bars[i] = b.k; } }, i * gap);
      });
      tl.add(c.st, { curve: 1, duration: 1100, ease: 'inOut(2)' }, dur * 0.85);
      tl.add(c.st, { mean: 1, duration: 500, ease: 'out(3)' }, dur * 0.85 + 800);
      if (d.loop !== undefined && d.loop !== 'false') {
        tl.then(function () {
          c.timer = setTimeout(function () {
            var out = { k: 1 }, from = c.st.bars.slice();
            anime.animate(out, {
              k: 0, duration: 450, ease: 'in(2)',
              onUpdate: function () {
                c.st.bars = from.map(function (v) { return v * out.k; });
                c.st.curve = out.k; c.st.mean = out.k;
                render(c);
              },
              onComplete: function () {
                if (c.made) {
                  c.seed = (c.seed * 48271) % 2147483647;
                  c.values = sample(c.count, c.seed);
                  analyse(c);
                  label(c);
                  c.st.bars = [];
                  layout(c);
                }
                setTimeout(function () { play(c); }, 200);
              }
            });
          }, 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._hwc);
        });
      }, { threshold: 0.3 });
      var ro = new ResizeObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._hwc;
          if (c && (e.target.clientWidth !== c.W || e.target.clientHeight !== c.H)) layout(c);
        });
      });
      document.querySelectorAll('[data-histogram-with-curve]').forEach(function (el) {
        if (el._hwc) return;
        var c = setup(el);
        ro.observe(el);
        if (reduced()) { finish(c); return; }
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-histogram-with-curve="212,198,240,225,231,205,219,250,188,227"
     data-suffix="ms" style="height: 240px"></div>