← Generative art

Penrose Tiling

Fat and thin rhombs fit together into a pattern that covers the box but never repeats. It grows outwards from a ten-pointed star in the middle, tile by tile, each one popping into place. Loop it and the tiles fall away from the edges in, then it grows again at a new angle.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-penrose-tiling to a box with a height and a dark background.

Options

Add any of these attributes to the same element.

data-sizeRough length of a tile’s edge in px. Default 26.
data-colorsFat rhomb, thin rhomb and line colours. Default #7c3aed,#f472b6,#1a1033.
data-durationTime to fill the box. Default 5000 ms.
data-loopHold, let the tiles fall away and grow again.

It’s Roger Penrose’s P3 tiling, made by repeatedly cutting a wheel of triangles into smaller ones by the golden ratio and pairing the halves into rhombs. It’s paused when off screen, and with reduced motion it shows the whole tiling.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Penrose Tiling: a tiling of fat and thin rhombs that covers the box
    // without ever repeating. It grows outwards from a ten-fold star in the
    // middle, tile by tile, each one popping into place. With data-loop it
    // holds, the tiles fall away from the edges inwards, and it grows again
    // at a new angle.
    // Usage: <div data-penrose-tiling style="height:360px"></div>
    // Optional: data-size (rough tile edge in px, default 26), data-colors
    // (fat rhomb, thin rhomb and line colours, default
    // "#7c3aed,#f472b6,#1a1033"), data-duration (ms to fill the box,
    // default 5000), data-loop (fall away and grow again).
    var DEFAULTS = { size: 26, colors: '#7c3aed,#f472b6,#1a1033', duration: 5000, hold: 2400 };
    var PHI = (1 + Math.sqrt(5)) / 2;

    var css =
      '[data-penrose-tiling]{position:relative;isolation:isolate;overflow:hidden}' +
      '.pnr-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // Split each Robinson triangle into smaller ones (the P3 "deflation").
    // A triangle is [kind, A, B, C], points as [x, y].
    function subdivide(tris) {
      var out = [];
      tris.forEach(function (t) {
        var A = t[1], B = t[2], C = t[3];
        if (t[0] === 0) {
          var P = [A[0] + (B[0] - A[0]) / PHI, A[1] + (B[1] - A[1]) / PHI];
          out.push([0, C, P, B], [1, P, C, A]);
        } else {
          var Q = [B[0] + (A[0] - B[0]) / PHI, B[1] + (A[1] - B[1]) / PHI];
          var R = [B[0] + (C[0] - B[0]) / PHI, B[1] + (C[1] - B[1]) / PHI];
          out.push([1, R, C, A], [1, Q, R, B], [0, R, Q, A]);
        }
      });
      return out;
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'pnr-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var size = +d.size || DEFAULTS.size;
      var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
      var duration = +d.duration || DEFAULTS.duration;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var w = 0, h = 0, dpr = 1, tiles = [], clock = 0, phase = 'grow', phaseAt = 0, turn = 0;

      function resize() {
        var nw = el.clientWidth, nh = el.clientHeight;
        if (nw === w && nh === h) return false;
        w = nw; h = nh;
        dpr = Math.min(2, window.devicePixelRatio || 1);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        return true;
      }

      function make() {
        var cx = w / 2, cy = h / 2;
        var R = Math.sqrt(w * w + h * h) / 2 * 1.15;
        var depth = Math.max(2, Math.ceil(Math.log(R / size) / Math.log(PHI)));
        var tris = [];
        // A wheel of ten triangles round the middle.
        for (var i = 0; i < 10; i++) {
          var B = [cx + R * Math.cos((2 * i - 1) * Math.PI / 10 + turn), cy + R * Math.sin((2 * i - 1) * Math.PI / 10 + turn)];
          var C = [cx + R * Math.cos((2 * i + 1) * Math.PI / 10 + turn), cy + R * Math.sin((2 * i + 1) * Math.PI / 10 + turn)];
          if (i % 2 === 0) { var s = B; B = C; C = s; }
          tris.push([0, [cx, cy], B, C]);
        }
        for (var k = 0; k < depth; k++) tris = subdivide(tris);

        // Pair the two halves of each rhomb: they share the edge B-C.
        var halves = {};
        tiles = [];
        tris.forEach(function (t) {
          var B = t[2], C = t[3];
          var k1 = Math.round(B[0] * 10) + ',' + Math.round(B[1] * 10), k2 = Math.round(C[0] * 10) + ',' + Math.round(C[1] * 10);
          var key = t[0] + '|' + (k1 < k2 ? k1 + '|' + k2 : k2 + '|' + k1);
          if (halves[key]) {
            var o = halves[key];
            delete halves[key];
            tiles.push({ kind: t[0], pts: [t[1], B, o[1], C] });
          } else halves[key] = t;
        });
        Object.keys(halves).forEach(function (key) {
          var t = halves[key];
          tiles.push({ kind: t[0], pts: [t[1], t[2], t[3]] });
        });

        // Keep the tiles that touch the box, nearest the middle first.
        var pad = size * 2;
        tiles = tiles.filter(function (t) {
          var mx = 0, my = 0;
          t.pts.forEach(function (p) { mx += p[0]; my += p[1]; });
          t.x = mx / t.pts.length;
          t.y = my / t.pts.length;
          return t.x > -pad && t.x < w + pad && t.y > -pad && t.y < h + pad;
        });
        tiles.forEach(function (t) {
          t.d = Math.sqrt((t.x - cx) * (t.x - cx) + (t.y - cy) * (t.y - cy)) + Math.random() * size * 0.8;
          t.shade = Math.random() * 0.18 - 0.09;
        });
        tiles.sort(function (a, b) { return a.d - b.d; });
        var far = tiles.length ? tiles[tiles.length - 1].d : 1;
        tiles.forEach(function (t, i) {
          // Spread the arrivals so the front moves at a steady pace.
          t.at = Math.sqrt(i / tiles.length) * 0.35 * duration + (t.d / far) * 0.65 * duration;
        });
      }

      function start() {
        make();
        clock = 0;
        phase = 'grow';
      }

      function easeOutBack(x) {
        var c1 = 1.70158, c3 = c1 + 1;
        return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2);
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.lineJoin = 'round';
        ctx.lineWidth = Math.max(1, size * 0.07);
        ctx.strokeStyle = colors[2] || '#1a1033';
        var far = tiles.length ? tiles[tiles.length - 1].d : 1;
        for (var i = 0; i < tiles.length; i++) {
          var t = tiles[i], p;
          if (phase === 'leave') {
            // Fall away from the edges inwards.
            var outAt = (1 - t.d / far) * 1100;
            p = 1 - Math.min(1, Math.max(0, (clock - phaseAt - outAt) / 350));
            p = p * p;
          } else {
            p = Math.min(1, Math.max(0, (clock - t.at) / 420));
            if (p < 1) p = easeOutBack(p);
          }
          if (p <= 0) continue;
          ctx.globalAlpha = Math.min(1, p * 1.5);
          ctx.fillStyle = t.kind === 1 ? colors[0] : colors[1] || colors[0];
          ctx.beginPath();
          for (var k = 0; k < t.pts.length; k++) {
            var x = t.x + (t.pts[k][0] - t.x) * p, y = t.y + (t.pts[k][1] - t.y) * p;
            if (k === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
          }
          ctx.closePath();
          ctx.fill();
          // A light or dark wash, so the tiles aren't flat.
          ctx.fillStyle = t.shade > 0 ? 'rgba(255,255,255,' + t.shade.toFixed(3) + ')' : 'rgba(0,0,0,' + (-t.shade).toFixed(3) + ')';
          ctx.fill();
          ctx.stroke();
        }
        ctx.globalAlpha = 1;
      }

      function tick(dt) {
        clock += dt;
        if (phase === 'grow' && clock > duration + 500) { phase = 'hold'; phaseAt = clock; }
        else if (phase === 'hold' && loop && clock - phaseAt > DEFAULTS.hold) { phase = 'leave'; phaseAt = clock; }
        else if (phase === 'leave' && clock - phaseAt > 1600) { turn += Math.PI / 7; start(); }
      }

      resize();
      start();
      if (reduced()) {
        clock = duration * 2;
        draw();
        if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) { make(); draw(); } }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) start(); }).observe(el);

      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) : 16;
          last = now;
          tick(dt);
          draw();
        }
      });
      // Pause while off screen to save battery.
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-penrose-tiling]').forEach(function (el) {
        if (el._pnr) return;
        el._pnr = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-penrose-tiling data-loop style="height: 360px; background: #1a1033"></div>