← Generative art

Crystal Growth

A cloud of specks wanders about at random, and any speck that bumps into the crystal sticks to it. The tips stick out furthest, so they catch the most specks, and the crystal grows into branching, feathery arms, the way frost creeps across a window. Wide boxes grow a few crystals at once.

How to use it

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

Options

Add any of these attributes to the same element.

data-colorsColours from the heart of the crystal out to its tips. Default #e0f2fe,#7dd3fc,#a78bfa,#f472b6.
data-grainSize of one speck in px. Default 3.
data-seedsHow many crystals. Default 1 to 3, depending on the box’s shape.
data-seed="bottom"Grow up from the bottom edge, like frost, instead of from the middle.
data-speedSpeed multiplier. Default 1.
data-loopHold the grown crystal, fade it and grow another.

It’s diffusion-limited aggregation. To save time, a speck far from any crystal takes one long random hop instead of hundreds of tiny steps, which ends up in the same places. It’s paused when off screen, and with reduced motion it shows a grown crystal.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Crystal Growth: a cloud of specks wanders at random, and any speck that
    // bumps into the crystal sticks to it. Because the tips stick out
    // furthest, they catch the most specks, so the crystal grows into
    // branching, feathery arms (this is diffusion-limited aggregation, the
    // way frost and mineral dendrites form). Wide boxes grow a few crystals.
    // With data-loop it holds, fades and grows again.
    // Usage: <div data-crystal-growth style="height:360px;background:#0b1026"></div>
    // Optional: data-colors (colours from the heart of the crystal out to its
    // tips, default "#e0f2fe,#7dd3fc,#a78bfa,#f472b6"), data-grain (size of
    // one speck in px, default 3), data-seeds (how many crystals, default 1
    // to 3 depending on the box's shape), data-seed="bottom" (grow up from
    // the bottom edge instead), data-speed (speed multiplier, default 1),
    // data-loop (fade and grow again).
    var DEFAULTS = { colors: '#e0f2fe,#7dd3fc,#a78bfa,#f472b6', grain: 3, speed: 1, hold: 2600 };

    var css =
      '[data-crystal-growth]{position:relative;isolation:isolate;overflow:hidden}' +
      '.cry-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 mix(a, b, t) {
      var x = parseInt(a.slice(1), 16), y = parseInt(b.slice(1), 16);
      var r = Math.round(((x >> 16) & 255) * (1 - t) + ((y >> 16) & 255) * t);
      var g = Math.round(((x >> 8) & 255) * (1 - t) + ((y >> 8) & 255) * t);
      var bl = Math.round((x & 255) * (1 - t) + (y & 255) * t);
      return 'rgb(' + r + ',' + g + ',' + bl + ')';
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'cry-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      // The crystal itself is drawn once per speck onto a buffer.
      var buf = document.createElement('canvas'), bctx = buf.getContext('2d');
      var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
      var grain = +d.grain || DEFAULTS.grain;
      var speed = +d.speed || DEFAULTS.speed;
      var bottom = d.seed === 'bottom';
      var loop = d.loop !== undefined && d.loop !== 'false';
      var w = 0, h = 0, dpr = 1, cols = 0, rows = 0, grid = null, seeds = [], walkers = [];
      var stuck = 0, target = 0, phase = 'grow', phaseAt = 0, clock = 0, fade = 1, reach = 1;

      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 = buf.width = Math.round(w * dpr);
        canvas.height = buf.height = Math.round(h * dpr);
        cols = Math.floor(w / grain);
        rows = Math.floor(h / grain);
        return true;
      }

      function colourAt(t) {
        t = Math.max(0, Math.min(1, t)) * (colors.length - 1);
        var i = Math.min(colors.length - 2, Math.floor(t));
        return colors.length > 1 ? mix(colors[i], colors[i + 1], t - i) : colors[0];
      }

      function stick(x, y, s) {
        grid[y * cols + x] = 1;
        stuck++;
        var sd = seeds[s];
        var dist = bottom ? rows - 1 - y : Math.sqrt((x - sd.x) * (x - sd.x) + (y - sd.y) * (y - sd.y));
        if (!bottom && dist + 1 > sd.r) sd.r = dist + 1;
        if (bottom && dist + 1 > sd.r) sd.r = dist + 1;
        bctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        bctx.fillStyle = colourAt(dist / reach);
        bctx.beginPath();
        bctx.arc((x + 0.5) * grain, (y + 0.5) * grain, grain * 0.62, 0, Math.PI * 2);
        bctx.fill();
      }

      function start() {
        grid = new Uint8Array(cols * rows);
        bctx.setTransform(1, 0, 0, 1, 0, 0);
        bctx.clearRect(0, 0, buf.width, buf.height);
        stuck = 0;
        seeds = [];
        if (bottom) {
          seeds.push({ x: 0, y: rows - 1, r: 1 });
          reach = rows * 0.85;
          for (var x = 0; x < cols; x++) stick(x, rows - 1, 0);
        } else {
          var n = +d.seeds || Math.max(1, Math.min(3, Math.round(w / h * 0.8)));
          reach = Math.min(rows / 2, cols / n / 2) * 0.95;
          for (var s = 0; s < n; s++) {
            seeds.push({ x: Math.floor(cols * (s + 0.5) / n), y: Math.floor(rows / 2), r: 1 });
            stick(seeds[s].x, seeds[s].y, s);
          }
        }
        target = Math.round(cols * rows * 0.16);
        walkers = [];
        var nw = Math.max(80, Math.min(400, Math.round(w * h / 900)));
        for (var i = 0; i < nw; i++) walkers.push(spawn({}));
        phase = 'grow';
        fade = 1;
      }

      // How far a cell is from the nearest crystal's outer reach, in cells.
      function gap(x, y) {
        if (bottom) return rows - 1 - seeds[0].r - y;
        var best = 1e9;
        for (var s = 0; s < seeds.length; s++) {
          var sd = seeds[s], dd = Math.sqrt((x - sd.x) * (x - sd.x) + (y - sd.y) * (y - sd.y)) - sd.r;
          if (dd < best) best = dd;
        }
        return best;
      }

      function spawn(wk) {
        for (var t = 0; t < 30; t++) {
          wk.x = Math.floor(Math.random() * cols);
          wk.y = Math.floor(Math.random() * rows);
          if (gap(wk.x, wk.y) > 3) break;
        }
        wk.px = wk.x; wk.py = wk.y;
        return wk;
      }

      function nearest(x, y) {
        if (bottom) return 0;
        var best = 0, bd = 1e9;
        seeds.forEach(function (sd, s) {
          var dd = (x - sd.x) * (x - sd.x) + (y - sd.y) * (y - sd.y);
          if (dd < bd) { bd = dd; best = s; }
        });
        return best;
      }

      function touching(x, y) {
        for (var yy = y - 1; yy <= y + 1; yy++) {
          if (yy < 0 || yy >= rows) continue;
          for (var xx = x - 1; xx <= x + 1; xx++) {
            if (xx >= 0 && xx < cols && grid[yy * cols + xx]) return true;
          }
        }
        return false;
      }

      function walk(wk, steps) {
        wk.px = wk.x; wk.py = wk.y;
        for (var i = 0; i < steps; i++) {
          var g = gap(wk.x, wk.y);
          if (g > 5) {
            // Far from any crystal: take one long random hop instead of many short steps.
            var a = Math.random() * Math.PI * 2, len = (g - 3) * 0.7;
            wk.x = Math.round(wk.x + Math.cos(a) * len);
            wk.y = Math.round(wk.y + Math.sin(a) * len);
          } else {
            wk.x += Math.floor(Math.random() * 3) - 1;
            wk.y += Math.floor(Math.random() * 3) - 1;
          }
          if (wk.x < 0 || wk.y < 0 || wk.x >= cols || wk.y >= rows) { spawn(wk); return; }
          if (grid[wk.y * cols + wk.x]) { spawn(wk); return; }
          if (touching(wk.x, wk.y)) {
            stick(wk.x, wk.y, nearest(wk.x, wk.y));
            spawn(wk);
            return;
          }
        }
      }

      function done() {
        if (stuck >= target) return true;
        if (bottom) return seeds[0].r >= rows * 0.88;
        return seeds.every(function (sd) { return sd.r >= reach; });
      }

      function tick(dt) {
        clock += dt;
        if (phase === 'grow') {
          var steps = Math.round(30 * speed * dt / 16);
          for (var i = 0; i < walkers.length; i++) walk(walkers[i], steps);
          if (done()) { phase = 'hold'; phaseAt = clock; }
        } else if (phase === 'hold' && loop && clock - phaseAt > DEFAULTS.hold) {
          phase = 'fade';
          phaseAt = clock;
        } else if (phase === 'fade') {
          fade = Math.max(0, 1 - (clock - phaseAt) / 800);
          if (fade === 0) start();
        }
      }

      function draw() {
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        ctx.globalAlpha = fade;
        ctx.drawImage(buf, 0, 0);
        ctx.globalAlpha = 1;
        if (phase !== 'grow') return;
        // The wandering specks, faint.
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.fillStyle = 'rgba(224,242,254,.7)';
        var s = Math.max(1.5, grain * 0.7);
        walkers.forEach(function (wk) {
          ctx.fillRect((wk.x + 0.5) * grain - s / 2, (wk.y + 0.5) * grain - s / 2, s, s);
        });
      }

      function still() {
        start();
        for (var i = 0; i < 20000 && !done(); i++) walkers.forEach(function (wk) { walk(wk, 30); });
        phase = 'hold';
        draw();
      }

      resize();
      start();
      if (reduced()) {
        still();
        if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) still(); }).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-crystal-growth]').forEach(function (el) {
        if (el._cry) return;
        el._cry = true;
        build(el);
      });
    }

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

<!-- frost from the bottom edge: -->
<div data-crystal-growth data-seed="bottom" style="height: 360px; background: #0b1026"></div>