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
- Paste the snippet just before
</head>. - Add
data-heatmap-fillto an element with a height, withdata-rowsanddata-cols(the labels) anddata-values(every cell, row by row).
Options
Add any of these attributes to the same element.
data-color | Colour of the strongest cells. Default #a855f7. |
data-sweep | diagonal (default), left, top, centre or random. |
data-show-values | Print each value in its cell. |
data-legend | false hides the Less–More key. |
data-size | Rows × columns of made-up data, e.g. 7x12, when there are no values or labels. |
data-stagger | Time between steps of the sweep. Default 45 ms. |
data-loop | Sweep away and fill again every few seconds. |
<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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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><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>