← Generative art

Reaction-diffusion

Two imaginary chemicals spread across the box and react where they meet. From a few scattered seeds, spots and stripes grow, split and settle, just like the markings on a leopard or a zebrafish.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-reaction-diffusion to a box with a height and a light background.

Options

Add any of these attributes to the same element.

data-patternspots, stripes, maze or coral. Default spots. A comma-separated list takes turns with data-loop.
data-loopOnce the pattern has grown, hold it, dissolve it and grow the next one.
data-colorColour of the markings. Default #2a1a12.
data-scaleSize of one simulation cell in px. Bigger is blobbier and lighter on the processor. Default 3.
data-speedSimulation steps per frame. Default 12.

It’s the Gray-Scott model, a cousin of the maths Alan Turing proposed for animal markings, run on a transparent canvas so your background shows through. It’s paused when off screen, and with reduced motion it shows the grown pattern.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Reaction-diffusion: two imaginary chemicals spread across the box and
    // react where they meet (the Gray-Scott model). From a few scattered
    // seeds, spots and stripes grow and split like the markings on an animal's
    // coat. Drawn on a transparent canvas and paused when off screen.
    // Usage: <div data-reaction-diffusion style="height:360px;background:#f4dfb8"></div>
    // Optional: data-pattern ("spots", "stripes", "maze" or "coral"; default
    // spots; a comma-separated list takes turns with data-loop), data-loop
    // (once the pattern has grown, hold it, dissolve, and grow the next one),
    // data-color (markings colour, default #2a1a12), data-scale (size of a
    // simulation cell in px, default 3), data-speed (simulation steps per
    // frame, default 12).
    var DEFAULTS = { pattern: 'spots', color: '#2a1a12', scale: 3, speed: 12, grow: 11000 };
    // Feed and kill rates for each pattern.
    var PATTERNS = {
      spots: [0.0367, 0.0649],
      stripes: [0.042, 0.0635],
      maze: [0.029, 0.057],
      coral: [0.0545, 0.062]
    };

    var css =
      '[data-reaction-diffusion]{position:relative;isolation:isolate;overflow:hidden}' +
      '.rxd-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 = 'rxd-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var names = (d.pattern || DEFAULTS.pattern).split(',').map(function (p) { return p.trim(); })
        .filter(function (p) { return PATTERNS[p]; });
      if (!names.length) names = [DEFAULTS.pattern];
      var which = 0;
      var n = parseInt((d.color || DEFAULTS.color).replace('#', ''), 16);
      var cr = n >> 16, cg = n >> 8 & 255, cb = n & 255;
      var scale = +d.scale || DEFAULTS.scale;
      var speed = +d.speed || DEFAULTS.speed;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var w = 0, h = 0, dpr = 1, cols = 0, rows = 0, cell = scale;
      var U, V, U2, V2, img, off = document.createElement('canvas'), octx = off.getContext('2d');
      var F = 0, K = 0, clock = 0, waiting = false;

      function seed() {
        var p = PATTERNS[names[which % names.length]];
        F = p[0]; K = p[1];
        U.fill(1); V.fill(0);
        // A scattering of small seeds, about one per 3,000 cells.
        var count = Math.max(4, Math.round(cols * rows / 3000));
        for (var s = 0; s < count; s++) {
          var cx = Math.floor(Math.random() * cols), cy = Math.floor(Math.random() * rows), r = 2 + Math.floor(Math.random() * 2);
          for (var y = -r; y <= r; y++) {
            for (var x = -r; x <= r; x++) {
              var i = ((cy + y + rows) % rows) * cols + (cx + x + cols) % cols;
              U[i] = 0.5; V[i] = 0.25 + Math.random() * 0.05;
            }
          }
        }
        clock = 0;
      }

      function resize() {
        var nw = el.clientWidth, nh = el.clientHeight;
        if (nw === w && nh === h) return;
        w = nw; h = nh;
        dpr = Math.min(2, window.devicePixelRatio || 1);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        // Keep the simulation to about 40,000 cells, whatever the size.
        cell = Math.max(scale, Math.sqrt(w * h / 40000));
        cols = Math.max(16, Math.ceil(w / cell));
        rows = Math.max(16, Math.ceil(h / cell));
        U = new Float32Array(cols * rows); V = new Float32Array(cols * rows);
        U2 = new Float32Array(cols * rows); V2 = new Float32Array(cols * rows);
        off.width = cols; off.height = rows;
        img = octx.createImageData(cols, rows);
        seed();
      }

      // One Gray-Scott step. The edges wrap round.
      function step() {
        for (var y = 0; y < rows; y++) {
          var up = ((y + rows - 1) % rows) * cols, mid = y * cols, dn = ((y + 1) % rows) * cols;
          for (var x = 0; x < cols; x++) {
            var l = x === 0 ? cols - 1 : x - 1, r = x === cols - 1 ? 0 : x + 1, i = mid + x;
            var u = U[i], v = V[i];
            var lu = 0.2 * (U[mid + l] + U[mid + r] + U[up + x] + U[dn + x]) +
              0.05 * (U[up + l] + U[up + r] + U[dn + l] + U[dn + r]) - u;
            var lv = 0.2 * (V[mid + l] + V[mid + r] + V[up + x] + V[dn + x]) +
              0.05 * (V[up + l] + V[up + r] + V[dn + l] + V[dn + r]) - v;
            var uvv = u * v * v;
            U2[i] = u + lu - uvv + F * (1 - u);
            V2[i] = v + 0.5 * lv + uvv - (F + K) * v;
          }
        }
        var t = U; U = U2; U2 = t;
        t = V; V = V2; V2 = t;
      }

      function draw() {
        var data = img.data;
        for (var i = 0, j = 0; i < V.length; i++, j += 4) {
          // A soft threshold, so the markings have clean edges.
          var a = (V[i] - 0.08) / 0.17;
          a = a < 0 ? 0 : a > 1 ? 1 : a * a * (3 - 2 * a);
          data[j] = cr; data[j + 1] = cg; data[j + 2] = cb; data[j + 3] = a * 255;
        }
        octx.putImageData(img, 0, 0);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.imageSmoothingEnabled = true;
        ctx.imageSmoothingQuality = 'high';
        ctx.drawImage(off, 0, 0, cols * cell, rows * cell);
      }

      function advance(dt) {
        for (var k = 0; k < speed; k++) step();
        clock += dt;
        if (loop && !waiting && clock > DEFAULTS.grow) {
          waiting = true;
          anime.animate(canvas, {
            opacity: 0, duration: 900, delay: 1500, ease: 'inOut(2)',
            onComplete: function () {
              which++;
              seed();
              waiting = false;
              anime.animate(canvas, { opacity: 1, duration: 300 });
            }
          });
        }
      }

      resize();
      if (reduced()) {
        // A still frame of the grown pattern.
        for (var k = 0; k < 1500; k++) step();
        draw();
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(resize).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;
          advance(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-reaction-diffusion]').forEach(function (el) {
        if (el._rxd) return;
        el._rxd = true;
        build(el);
      });
    }

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

<!-- take turns: -->
<div data-reaction-diffusion data-pattern="spots,stripes,maze" data-loop
     style="height: 360px; background: #f4dfb8"></div>