Isometric Block Grid
A square of isometric blocks rises and falls in slow swells, like a little city breathing. The tallest blocks blush pink. Point at a block, or tap it, and a ripple spreads out across the grid from there.
How to use it
- Paste the snippet just before
</head>. - Add
data-isometric-block-gridto a box with a height. The grid fits itself inside.
Options
Add any of these attributes to the same element.
data-cols | Blocks along each side. Default 9. |
data-pattern | wave (default) rolls diagonal swells across; ripple sends rings out from the centre; random moves each block on its own. |
data-color | Colour of the low blocks. Default #7c3aed. |
data-peak | Colour of the tallest blocks. Default #f0abfc. |
data-height | Tallest block, as a share of a block’s width. Default 1.4. |
data-speed | Speed of the swells. Default 1. |
data-pointer="false" | No ripples from the pointer or taps. |
Drawn on a canvas, paused when off screen. With reduced motion it shows a still frame.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Isometric Block Grid: a square grid of isometric blocks rising and
// falling in waves, like a city skyline breathing. Point at (or tap) a
// block and a ripple spreads out from it. Drawn on a canvas, and paused
// when off screen.
// Usage: <div data-isometric-block-grid style="height: 320px"></div>
// Optional: data-cols (blocks along each side, default 9), data-color
// (colour of the low blocks, default #7c3aed), data-peak (colour of the
// tallest, default #f0abfc), data-pattern ("wave", default: diagonal
// swells; "ripple": rings from the centre; "random": each block on its
// own), data-speed (default 1), data-height (tallest block as a share of
// a block's width, default 1.4), data-pointer="false".
var DEFAULTS = { cols: 9, color: '#7c3aed', peak: '#f0abfc', speed: 1, height: 1.4 };
var css =
'[data-isometric-block-grid]{position:relative}' +
'.ibg-canvas{position:absolute;inset:0;width:100%;height:100%;display:block}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function rgb(hex) {
hex = hex.replace('#', '');
if (hex.length === 3) hex = hex.replace(/./g, '$&$&');
var n = parseInt(hex, 16);
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
function mix(a, b, t, shade) {
var c = [0, 1, 2].map(function (i) { return Math.round((a[i] + (b[i] - a[i]) * t) * shade); });
return 'rgb(' + c.join(',') + ')';
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'ibg-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var N = Math.max(2, Math.min(30, +d.cols || DEFAULTS.cols));
var lo = rgb(d.color || DEFAULTS.color), hi = rgb(d.peak || DEFAULTS.peak);
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var tall = +d.height || DEFAULTS.height;
var pattern = d.pattern || 'wave';
var w = 0, h = 0, dpr = 1, tw = 0, ox = 0, oy = 0, t = 0;
var ripples = [], hover = null;
var phase = [];
for (var i = 0; i < N * N; i++) phase.push(Math.random() * Math.PI * 2);
var heights = new Float32Array(N * N);
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
// The grid is N tiles wide and N/2 tiles tall, plus room for the tallest block.
tw = Math.min((w * 0.9) / N, (h * 0.86) / (N / 2 + tall + 0.1));
ox = w / 2;
oy = (h - tw * (N / 2) - tw * tall) / 2 + tw * tall;
}
// Height of each block, 0 to 1.
function level(i, j) {
var c = (N - 1) / 2, v;
if (pattern === 'ripple') {
var r = Math.sqrt((i - c) * (i - c) + (j - c) * (j - c));
v = 0.5 + 0.5 * Math.sin(r * 0.9 - t * 2.4);
} else if (pattern === 'random') {
v = 0.5 + 0.5 * Math.sin(t * (0.8 + (phase[i * N + j] % 1)) + phase[i * N + j]);
} else {
v = 0.5 + 0.35 * Math.sin((i + j) * 0.55 - t * 1.8) + 0.15 * Math.sin(i * 0.9 - j * 0.4 + t * 1.1);
}
v = 0.12 + v * 0.7;
for (var k = 0; k < ripples.length; k++) {
var rp = ripples[k];
var dist = Math.sqrt((i - rp.i) * (i - rp.i) + (j - rp.j) * (j - rp.j));
var front = rp.age * 7 - dist;
if (front > 0 && front < 3) v += Math.sin(front / 3 * Math.PI) * 0.5 * Math.max(0, 1 - rp.age / 2.2);
}
if (hover && hover.i === i && hover.j === j) v += 0.25;
return Math.max(0.06, Math.min(1.25, v));
}
function block(i, j, lv) {
var hw = tw / 2 * 0.9, hh = tw / 4 * 0.9;
var x = ox + (i - j) * tw / 2;
var y = oy + (i + j) * tw / 4 + tw / 4;
var bh = lv * tw * tall;
var top = y - bh;
var k = Math.min(1, lv);
// Left face, right face, then the lid.
ctx.fillStyle = mix(lo, hi, k, 0.62);
ctx.beginPath();
ctx.moveTo(x - hw, top); ctx.lineTo(x, top + hh); ctx.lineTo(x, y + hh); ctx.lineTo(x - hw, y);
ctx.fill();
ctx.fillStyle = mix(lo, hi, k, 0.8);
ctx.beginPath();
ctx.moveTo(x + hw, top); ctx.lineTo(x, top + hh); ctx.lineTo(x, y + hh); ctx.lineTo(x + hw, y);
ctx.fill();
ctx.fillStyle = mix(lo, hi, k, 1);
ctx.beginPath();
ctx.moveTo(x, top - hh); ctx.lineTo(x + hw, top); ctx.lineTo(x, top + hh); ctx.lineTo(x - hw, top);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,.35)';
ctx.lineWidth = 1;
ctx.stroke();
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// Back to front: rows of equal i + j, so nearer blocks cover farther ones.
for (var s = 0; s <= 2 * (N - 1); s++) {
for (var i = Math.max(0, s - N + 1); i <= Math.min(N - 1, s); i++) {
var j = s - i;
var target = level(i, j), idx = i * N + j;
heights[idx] += (target - heights[idx]) * 0.25;
block(i, j, heights[idx]);
}
}
}
function settle() {
for (var i = 0; i < N; i++) for (var j = 0; j < N; j++) heights[i * N + j] = level(i, j);
}
// Which tile is under a point, by its footprint on the ground.
function tileAt(px, py) {
var a = (px - ox) / (tw / 2), b = (py - oy - tw / 4) / (tw / 4);
var i = Math.round((a + b) / 2), j = Math.round((b - a) / 2);
return i >= 0 && j >= 0 && i < N && j < N ? { i: i, j: j } : null;
}
resize();
settle();
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
t = 1;
settle();
draw();
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
if (d.pointer !== 'false') {
var point = function (e) {
var r = el.getBoundingClientRect();
return tileAt(e.clientX - r.left, e.clientY - r.top);
};
el.addEventListener('pointermove', function (e) {
if (e.pointerType === 'touch') return;
var tile = point(e);
if (tile && (!hover || hover.i !== tile.i || hover.j !== tile.j)) {
ripples.push({ i: tile.i, j: tile.j, age: 0 });
if (ripples.length > 4) ripples.shift();
}
hover = tile;
});
el.addEventListener('pointerleave', function () { hover = null; });
el.addEventListener('pointerdown', function (e) {
var tile = point(e);
if (tile) ripples.push({ i: tile.i, j: tile.j, age: 0 });
});
}
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) / 1000 : 0.016;
last = now;
t += dt * speed;
ripples.forEach(function (r) { r.age += dt; });
ripples = ripples.filter(function (r) { return r.age < 2.4; });
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-isometric-block-grid]').forEach(function (el) {
if (el._ibg) return;
el._ibg = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-isometric-block-grid style="height: 320px"></div>