← Generative art

Chaos Game Triangle

Start anywhere. Pick one of three corners at random, jump halfway towards it and leave a dot. Repeat. The first few jumps are shown slowly, then it speeds up, and out of pure chance a perfect Sierpiński triangle builds up, point by point.

How to use it

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

Options

Add any of these attributes to the same element.

data-sides3, 5 or 6 corners. Default 3. Five and six jump further, making pentagon and hexagon flakes.
data-pointsHow many dots in all. Default fits the box, 8,000 to 70,000.
data-colorsComma-separated colours, one per corner. Each dot takes the colour of the corner it jumped towards. Default pink, violet and sky.
data-loopHold the finished triangle, fade it and play again.

It’s paused when off screen. With reduced motion the finished triangle appears at once.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Chaos Game Triangle: start anywhere, pick one of three corners at
    // random, jump halfway towards it and leave a dot. Repeat. The first few
    // jumps are shown slowly, then it speeds up, and out of pure chance a
    // perfect Sierpinski triangle builds up, point by point. Each dot takes
    // the colour of the corner it jumped towards. Paused when off screen.
    // Usage: <div data-chaos-game-triangle style="height:360px"></div>
    // Optional: data-sides (3, 5 or 6 corners, default 3; five and six jump
    // further, so they make pentagon and hexagon flakes), data-points (how
    // many dots in all, default fits the box), data-colors (one colour per
    // corner, comma-separated, default pink, violet and sky), data-loop (hold
    // the finished triangle, fade it and play again).
    var DEFAULTS = { sides: 3, colors: '#f472b6,#a78bfa,#38bdf8,#fbbf24,#34d399,#fb923c', hold: 3000 };
    // How far each jump goes towards the corner, for a flake with no overlaps.
    var RATIO = { 3: 0.5, 5: 0.618, 6: 0.667 };

    var css =
      '[data-chaos-game-triangle]{position:relative;isolation:isolate;overflow:hidden}' +
      '.cgt-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 dots = document.createElement('canvas'), over = document.createElement('canvas');
      dots.className = over.className = 'cgt-canvas';
      dots.setAttribute('aria-hidden', 'true');
      over.setAttribute('aria-hidden', 'true');
      el.insertBefore(over, el.firstChild);
      el.insertBefore(dots, over);
      var dctx = dots.getContext('2d'), octx = over.getContext('2d');
      var sides = RATIO[+d.sides] ? +d.sides : DEFAULTS.sides, ratio = RATIO[sides];
      var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
      while (colors.length < sides) colors = colors.concat(colors);
      var loop = d.loop !== undefined && d.loop !== 'false';
      var w = 0, h = 0, dpr = 1, total = 0, corners = [], size = 1;
      var px = 0, py = 0, placed = 0, jump = null, age = 0, fast = 0, phase = 'play', fading = false;

      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);
        dots.width = over.width = Math.round(w * dpr);
        dots.height = over.height = Math.round(h * dpr);
        // Corners on a circle, fitted to the box (a triangle sits a little low).
        var pad = Math.max(14, Math.min(w, h) * 0.08);
        var r = sides === 3 ? Math.min((h - pad * 2) / 1.5, (w - pad * 2) / Math.sqrt(3)) : Math.min(w, h) / 2 - pad;
        var cx = w / 2, cy = sides === 3 ? pad + r : h / 2;
        corners = [];
        for (var i = 0; i < sides; i++) {
          var a = -Math.PI / 2 + i * Math.PI * 2 / sides;
          corners.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
        }
        total = +d.points || Math.round(Math.max(8000, Math.min(70000, w * h * 0.32)));
        size = Math.max(0.6, Math.min(1.2, Math.sqrt(w * h / total) * 0.55));
        return true;
      }

      function reset() {
        dctx.setTransform(1, 0, 0, 1, 0, 0);
        dctx.clearRect(0, 0, dots.width, dots.height);
        // Start somewhere random inside the box.
        px = w * (0.25 + Math.random() * 0.5);
        py = h * (0.25 + Math.random() * 0.5);
        placed = 0;
        age = 0;
        fast = 0;
        jump = null;
        phase = 'play';
      }

      function dot(x, y, k, big) {
        dctx.fillStyle = colors[k];
        var s = big ? 3 : size;
        dctx.fillRect(x - s / 2, y - s / 2, s, s);
      }

      // Jump n times in one go.
      function burst(n) {
        dctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        dctx.globalAlpha = 0.85;
        for (var i = 0; i < n && placed < total; i++) {
          var k = Math.floor(Math.random() * sides);
          px += (corners[k][0] - px) * ratio;
          py += (corners[k][1] - py) * ratio;
          if (placed > 6) dot(px, py, k, false);
          placed++;
        }
        dctx.globalAlpha = 1;
      }

      // The overlay: corners, and the jump in progress.
      function drawOver() {
        octx.setTransform(dpr, 0, 0, dpr, 0, 0);
        octx.clearRect(0, 0, w, h);
        for (var i = 0; i < sides; i++) {
          octx.fillStyle = colors[i];
          octx.beginPath();
          octx.arc(corners[i][0], corners[i][1], 4.5, 0, Math.PI * 2);
          octx.fill();
        }
        if (!jump) return;
        var e = 1 - Math.pow(1 - Math.min(1, jump.t), 3);
        var x = jump.x0 + (jump.x1 - jump.x0) * e, y = jump.y0 + (jump.y1 - jump.y0) * e;
        octx.strokeStyle = colors[jump.k];
        octx.globalAlpha = 0.5;
        octx.setLineDash([3, 4]);
        octx.lineWidth = 1.2;
        octx.beginPath();
        octx.moveTo(jump.x0, jump.y0);
        octx.lineTo(corners[jump.k][0], corners[jump.k][1]);
        octx.stroke();
        octx.setLineDash([]);
        octx.globalAlpha = 1;
        octx.fillStyle = '#fff';
        octx.beginPath();
        octx.arc(x, y, 3.2, 0, Math.PI * 2);
        octx.fill();
      }

      function tick(dt) {
        age += dt;
        if (phase === 'hold') {
          if (loop && age > DEFAULTS.hold && !fading) {
            fading = true;
            anime.animate(dots, {
              opacity: 0, duration: 600, ease: 'inOut(2)',
              onComplete: function () { reset(); fading = false; anime.utils.set(dots, { opacity: 1 }); }
            });
          }
          return;
        }
        // The first dozen jumps one at a time, so you can follow them.
        if (placed < 12) {
          if (!jump) {
            var k = Math.floor(Math.random() * sides);
            jump = { k: k, x0: px, y0: py, x1: px + (corners[k][0] - px) * ratio, y1: py + (corners[k][1] - py) * ratio, t: 0 };
          }
          jump.t += dt / Math.max(100, 440 - placed * 36);
          if (jump.t >= 1.25) {
            px = jump.x1; py = jump.y1;
            dot(px, py, jump.k, true);
            placed++;
            jump = null;
          }
          return;
        }
        // Then faster and faster.
        fast += dt;
        burst(Math.max(1, Math.min(total / 120, Math.round(Math.pow(fast / 1000, 2.2) * 40))));
        if (placed >= total) { phase = 'hold'; age = 0; }
      }

      resize();
      reset();
      if (reduced()) {
        // The finished triangle.
        placed = 12;
        burst(total);
        drawOver();
        if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) { reset(); placed = 12; burst(total); drawOver(); } }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) reset(); }).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;
          dctx.setTransform(dpr, 0, 0, dpr, 0, 0);
          tick(dt);
          drawOver();
        }
      });
      // 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-chaos-game-triangle]').forEach(function (el) {
        if (el._cgt) return;
        el._cgt = true;
        build(el);
      });
    }

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

<!-- a pentagon flake: -->
<div data-chaos-game-triangle data-sides="5" style="height: 360px; background: #0f0a1e"></div>