← 3D & depth

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

  1. Paste the snippet just before </head>.
  2. Add data-isometric-block-grid to a box with a height. The grid fits itself inside.

Options

Add any of these attributes to the same element.

data-colsBlocks along each side. Default 9.
data-patternwave (default) rolls diagonal swells across; ripple sends rings out from the centre; random moves each block on its own.
data-colorColour of the low blocks. Default #7c3aed.
data-peakColour of the tallest blocks. Default #f0abfc.
data-heightTallest block, as a share of a block’s width. Default 1.4.
data-speedSpeed 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.

1 · Snippet
<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>
2 · Your HTML
<div data-isometric-block-grid style="height: 320px"></div>