← Generative art

Truchet Tiles

A grid of square tiles, each holding two quarter-circle arcs. Whichever way round a tile sits, its arcs join up with its neighbours’, so the whole grid forms long, flowing curves. Waves roll across, turning tiles a quarter turn as they pass, and the curves keep re-routing themselves. Move the pointer, or drag a finger, to turn tiles yourself.

Move or drag to turn tiles

How to use it

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

Options

Add any of these attributes to the same element.

data-sizeTile size in px. Default 40.
data-colorsTwo colours the lines blend between across the box. Default #a855f7,#f472b6.
data-widthLine width as a share of the tile. Default 0.16.
data-intervalTime between waves. Default 2400 ms.
data-pointer="false"Ignore the pointer.

These are Smith’s version of Truchet’s tiles: every edge of every tile has a line meeting it at its midpoint, so any arrangement joins up. It’s paused when off screen, and with reduced motion it shows a still pattern.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Truchet Tiles: a grid of square tiles, each holding two quarter-circle
    // arcs. Every tile can sit one of two ways round, and whichever way they
    // sit, the arcs join up with their neighbours' into long flowing curves.
    // Waves roll across the grid, turning tiles a quarter turn as they pass,
    // so the curves keep re-routing themselves. The pointer (or a finger)
    // turns the tiles it passes over. Paused when off screen.
    // Usage: <div data-truchet-tiles style="height:360px;background:#160c2c"></div>
    // Optional: data-size (tile size in px, default 40), data-colors (two
    // colours the lines blend between across the box, default
    // "#a855f7,#f472b6"), data-width (line width as a share of the tile,
    // default 0.16), data-interval (ms between waves, default 2400),
    // data-pointer="false" (ignore the pointer).
    var DEFAULTS = { size: 40, colors: '#a855f7,#f472b6', width: 0.16, interval: 2400 };

    var css =
      '[data-truchet-tiles]{position:relative;isolation:isolate;overflow:hidden}' +
      '.trc-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; }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'trc-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(); });
      if (colors.length < 2) colors.push(colors[0]);
      var lineW = +d.width || DEFAULTS.width;
      var interval = +d.interval || DEFAULTS.interval;
      var usePointer = d.pointer !== 'false';
      var w = 0, h = 0, dpr = 1, cols = 0, rows = 0, ox = 0, oy = 0, tiles = [], grad = null;
      var clock = 0, nextWave = 600;

      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);
        cols = Math.ceil(w / size) + 1;
        rows = Math.ceil(h / size) + 1;
        // Centre the grid, so the cut-off tiles are even on both sides.
        ox = (w - cols * size) / 2;
        oy = (h - rows * size) / 2;
        tiles = [];
        for (var i = 0; i < cols * rows; i++) {
          var a = Math.random() < 0.5 ? 0 : Math.PI / 2;
          tiles.push({ a: a, t: a, at: -1 });
        }
        grad = ctx.createLinearGradient(0, 0, w, h);
        grad.addColorStop(0, colors[0]);
        grad.addColorStop(1, colors[1]);
        return true;
      }

      // Turn one tile a quarter turn (on top of any turn it's already making).
      function flip(t) { t.t += Math.PI / 2; }

      // A wave rolls out from a random spot, turning about half the tiles it meets.
      function wave() {
        var cx = Math.random() * cols, cy = Math.random() * rows;
        var chance = 0.35 + Math.random() * 0.3;
        for (var j = 0; j < rows; j++) {
          for (var i = 0; i < cols; i++) {
            if (Math.random() > chance) continue;
            var t = tiles[j * cols + i];
            t.at = clock + Math.sqrt((i - cx) * (i - cx) + (j - cy) * (j - cy)) * 55;
          }
        }
      }

      function tick(dt) {
        clock += dt;
        if (clock >= nextWave) { wave(); nextWave = clock + interval; }
        var k = Math.min(1, dt * 0.011);
        tiles.forEach(function (t) {
          if (t.at >= 0 && clock >= t.at) { t.at = -1; flip(t); }
          t.a += (t.t - t.a) * k;
          if (Math.abs(t.t - t.a) < 0.001) t.a = t.t;
        });
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        var r = size / 2;
        ctx.lineWidth = Math.max(1.5, size * lineW);
        ctx.lineCap = 'round';
        ctx.strokeStyle = grad;
        ctx.beginPath();
        for (var j = 0; j < rows; j++) {
          for (var i = 0; i < cols; i++) {
            var t = tiles[j * cols + i];
            var cx = ox + i * size + r, cy = oy + j * size + r;
            var c = Math.cos(t.a), s = Math.sin(t.a);
            // Corner (-r, -r) and corner (r, r), turned by the tile's angle.
            var ax = cx + (-r * c + r * s), ay = cy + (-r * s - r * c);
            var bx = cx + (r * c - r * s), by = cy + (r * s + r * c);
            ctx.moveTo(ax + r * c, ay + r * s);
            ctx.arc(ax, ay, r, t.a, t.a + Math.PI / 2);
            ctx.moveTo(bx - r * c, by - r * s);
            ctx.arc(bx, by, r, t.a + Math.PI, t.a + Math.PI * 1.5);
          }
        }
        ctx.stroke();
      }

      resize();
      draw();
      if (reduced()) {
        if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(); }).observe(el);

      if (usePointer) {
        var lastTile = -1;
        var touch = function (e) {
          var b = el.getBoundingClientRect();
          var i = Math.floor((e.clientX - b.left - ox) / size), j = Math.floor((e.clientY - b.top - oy) / size);
          if (i < 0 || j < 0 || i >= cols || j >= rows) return;
          var n = j * cols + i;
          if (n === lastTile) return;
          lastTile = n;
          flip(tiles[n]);
        };
        el.addEventListener('pointermove', function (e) { if (e.pointerType === 'mouse' || e.buttons) touch(e); });
        el.addEventListener('pointerdown', function (e) { lastTile = -1; touch(e); });
        el.addEventListener('pointerleave', function () { lastTile = -1; });
      }

      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-truchet-tiles]').forEach(function (el) {
        if (el._trc) return;
        el._trc = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-truchet-tiles style="height: 360px; background: #160c2c"></div>