← Charts

Heatmap Fill

A grid of cells that colour in with a sweep across the grid, each one blooming to a strength of colour that matches its value. Sweep diagonally, from the left, from the top, out from the centre or at random.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-heatmap-fill to an element with a height, with data-rows and data-cols (the labels) and data-values (every cell, row by row).

Options

Add any of these attributes to the same element.

data-colorColour of the strongest cells. Default #a855f7.
data-sweepdiagonal (default), left, top, centre or random.
data-show-valuesPrint each value in its cell.
data-legendfalse hides the Less–More key.
data-sizeRows × columns of made-up data, e.g. 7x12, when there are no values or labels.
data-staggerTime between steps of the sweep. Default 45 ms.
data-loopSweep away and fill again every few seconds.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Heatmap Fill: a grid of cells that colour in with a sweep across the
    // grid, each cell blooming to a strength of colour that matches its value.
    // Usage:
    //   <div data-heatmap-fill data-rows="Mon,Tue,Wed" data-cols="9am,12pm,3pm,6pm"
    //        data-values="2,8,5,1, 4,9,7,3, 1,6,9,4" style="height:200px"></div>
    // data-values lists every cell, row by row. Leave it out to show made-up
    // sample data (data-size="7x12" sets rows × columns when there are no labels).
    // Optional: data-color (default #a855f7), data-sweep="diagonal" (default),
    // "left", "top", "centre" or "random", data-show-values (print each
    // value in its cell), data-legend="false" (no Less–More key),
    // data-stagger (ms between steps of the sweep, default 45),
    // data-loop (sweep away and fill again every few seconds).
    var DEFAULTS = { color: '#a855f7', sweep: 'diagonal', stagger: 45, hold: 2600 };

    var css =
      '.htf{display:flex;flex-direction:column;gap:8px;font-size:11px;font-variant-numeric:tabular-nums}' +
      '.htf-grid{flex:1;min-height:0;display:grid;gap:3px}' +
      '.htf-cell{position:relative;border-radius:4px;background:color-mix(in srgb,currentColor 7%,transparent);overflow:hidden}' +
      '.htf-cell i{position:absolute;inset:0;border-radius:inherit;background:var(--htf-color);opacity:0}' +
      '.htf-cell span{position:absolute;inset:0;display:grid;place-items:center;font-weight:600;opacity:0}' +
      '.htf-row,.htf-col{align-self:center;opacity:.6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
      '.htf-row{text-align:right;padding-right:4px}' +
      '.htf-col{text-align:center;padding-top:2px}' +
      '.htf-legend{display:flex;align-items:center;justify-content:flex-end;gap:3px;opacity:.75}' +
      '.htf-legend i{width:11px;height:11px;border-radius:3px;background:var(--htf-color)}' +
      '.htf-legend em{font-style:normal;margin:0 4px}';
    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]; }); }

    // Made-up values with some shape to them: busier mid-week and mid-day.
    function sample(rows, cols) {
      var v = [];
      for (var r = 0; r < rows; r++) {
        for (var c = 0; c < cols; c++) {
          var a = Math.sin((c / Math.max(1, cols - 1)) * Math.PI) * Math.sin(((r + 0.5) / rows) * Math.PI);
          var noise = Math.sin(r * 12.9898 + c * 78.233) * 43758.5453;
          noise -= Math.floor(noise);
          v.push(Math.round((a * 0.75 + noise * 0.35) * 10));
        }
      }
      return v;
    }

    function setup(el) {
      var d = el.dataset;
      var rowNames = d.rows ? d.rows.split(',') : [];
      var colNames = d.cols ? d.cols.split(',') : [];
      var size = (d.size || '7x12').split('x').map(Number);
      var rows = rowNames.length || size[0], cols = colNames.length || size[1];
      var values = d.values ? d.values.split(',').map(Number) : sample(rows, cols);
      var max = Math.max.apply(null, values), min = Math.min.apply(null, values);
      var color = d.color || DEFAULTS.color;
      var showValues = d.showValues !== undefined && d.showValues !== 'false';
      var hasRows = rowNames.length > 0, hasCols = colNames.length > 0;

      el.classList.add('htf');
      el.style.setProperty('--htf-color', color);
      el.setAttribute('role', 'img');
      el.setAttribute('aria-label', 'Heatmap, ' + rows + ' rows by ' + cols + ' columns, values from ' + min + ' to ' + max + '.');

      var grid = '<div class="htf-grid" aria-hidden="true" style="grid-template-columns:' + (hasRows ? 'auto ' : '') +
        'repeat(' + cols + ',1fr);grid-template-rows:repeat(' + rows + ',1fr)' + (hasCols ? ' auto' : '') + '">';
      for (var r = 0; r < rows; r++) {
        if (hasRows) grid += '<span class="htf-row">' + esc(rowNames[r]) + '</span>';
        for (var c = 0; c < cols; c++) {
          var v = values[r * cols + c] || 0;
          var t = max > min ? (v - min) / (max - min) : 1;
          grid += '<div class="htf-cell" data-t="' + t.toFixed(3) + '" data-r="' + r + '" data-c="' + c + '"><i></i>' +
            (showValues ? '<span style="color:' + (t > 0.55 ? '#fff' : 'inherit') + '">' + v + '</span>' : '') + '</div>';
        }
      }
      if (hasCols) {
        if (hasRows) grid += '<span></span>';
        grid += colNames.map(function (n) { return '<span class="htf-col">' + esc(n) + '</span>'; }).join('');
      }
      grid += '</div>';
      var legend = d.legend === 'false' ? '' : '<div class="htf-legend" aria-hidden="true"><em>Less</em>' +
        [0.12, 0.3, 0.5, 0.72, 1].map(function (o) { return '<i style="opacity:' + o + '"></i>'; }).join('') + '<em>More</em></div>';
      el.innerHTML = grid + legend;

      var cells = Array.prototype.slice.call(el.querySelectorAll('.htf-cell'));
      var c = {
        el: el, rows: rows, cols: cols, cells: cells,
        fills: cells.map(function (n) { return n.querySelector('i'); }),
        nums: Array.prototype.slice.call(el.querySelectorAll('.htf-cell span')),
        t: cells.map(function (n) { return +n.getAttribute('data-t'); })
      };
      el._htf = c;
      return c;
    }

    // Which step of the sweep each cell is on.
    function order(c, sweep) {
      var cx = (c.cols - 1) / 2, cy = (c.rows - 1) / 2;
      return c.cells.map(function (n, i) {
        var r = Math.floor(i / c.cols), col = i % c.cols;
        if (sweep === 'left') return col + r * 0.15;
        if (sweep === 'top') return r * 1.6 + col * 0.1;
        if (sweep === 'centre' || sweep === 'center') return Math.sqrt((col - cx) * (col - cx) + (r - cy) * (r - cy)) * 1.4;
        if (sweep === 'random') return Math.random() * (c.cols + c.rows);
        return col + r;
      });
    }

    function opacity(t) { return 0.1 + t * 0.9; }

    function finish(c) {
      c.fills.forEach(function (f, i) { f.style.opacity = opacity(c.t[i]); });
      c.nums.forEach(function (s) { s.style.opacity = 1; });
    }

    function play(c) {
      var d = c.el.dataset;
      var stagger = +d.stagger || DEFAULTS.stagger;
      var steps = order(c, d.sweep || DEFAULTS.sweep);
      var last = Math.max.apply(null, steps) * stagger;
      anime.animate(c.fills, {
        opacity: function (f, i) { return [0, opacity(c.t[i])]; },
        scale: [{ from: 0.3, to: 1.12, duration: 260, ease: 'out(3)' }, { to: 1, duration: 300, ease: 'inOut(2)' }],
        duration: 560,
        delay: function (f, i) { return steps[i] * stagger; }
      });
      if (c.nums.length) {
        anime.animate(c.nums, { opacity: [0, 1], duration: 300, delay: function (s, i) { return steps[i] * stagger + 200; } });
      }
      if (d.loop !== undefined && d.loop !== 'false') {
        setTimeout(function () {
          var fade = c.fills.concat(c.nums);
          var all = steps.concat(steps);
          anime.animate(fade, {
            opacity: 0, duration: 320, ease: 'in(2)',
            delay: function (f, i) { return all[i] * stagger * 0.5; },
            onComplete: function () { play(c); }
          });
        }, last + 600 + 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._htf);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-heatmap-fill]').forEach(function (el) {
        if (el._htf) return;
        var c = setup(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-heatmap-fill
     data-rows="Mon,Tue,Wed"
     data-cols="9am,12pm,3pm,6pm"
     data-values="2,8,5,1, 4,9,7,3, 1,6,9,4"
     style="height: 180px"></div>