← Generative art

Coral Growth

A small loop of line keeps growing longer. Each point on it stays close to its two neighbours but pushes away from every other point nearby, so as it lengthens it has nowhere to go but to buckle and fold back on itself, like brain coral. Wide boxes grow a few loops that crowd each other.

How to use it

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

Options

Add any of these attributes to the same element.

data-colorsFill, edge and highlight colours. Default #fb7185,#9f1239,#fecdd3.
data-seedsHow many loops. Default 1 to 3, depending on the box’s shape.
data-pointsHow many points to grow to. More is finer and busier. Default scaled to the box, 600 to 2600.
data-speedSpeed multiplier. Default 1.
data-loopHold the grown coral, fade it and grow another.

This is “differential growth”: new points are added where the line stretches, and a few at random, and the crowding does the rest. It’s paused when off screen, and with reduced motion it shows the grown coral.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Coral Growth: a small loop of line grows longer and longer. Each point
    // on it keeps close to its two neighbours but pushes away from every
    // other point nearby, so as the line lengthens it has nowhere to go but
    // to buckle and fold back on itself, like coral or brain coral. Wide
    // boxes get a few loops that crowd each other. With data-loop it holds
    // the grown coral, fades it and grows another.
    // Usage: <div data-coral-growth style="height:360px;background:#082f49"></div>
    // Optional: data-colors (fill, edge and highlight colours, default
    // "#fb7185,#9f1239,#fecdd3"), data-seeds (how many loops, default 1 to 3
    // depending on the box's shape), data-points (how many points to grow to,
    // default scaled to the box, 600 to 2600), data-speed (speed multiplier,
    // default 1), data-loop (fade and grow again).
    var DEFAULTS = { colors: '#fb7185,#9f1239,#fecdd3', speed: 1, hold: 2600 };

    var css =
      '[data-coral-growth]{position:relative;isolation:isolate;overflow:hidden}' +
      '.crl-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 = 'crl-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
      var speed = +d.speed || DEFAULTS.speed;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var w = 0, h = 0, dpr = 1, R = 10, maxPts = 1000, loops = [], count = 0;
      var phase = 'grow', phaseAt = 0, clock = 0, fade = 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 = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        maxPts = +d.points || Math.max(600, Math.min(2600, Math.round(w * h / 160)));
        // Pick a spacing so the grown line fills about two-thirds of the box.
        R = Math.max(6, Math.sqrt(w * h * 0.62 / (maxPts * 0.5)));
        return true;
      }

      function start() {
        var seeds = +d.seeds || Math.max(1, Math.min(3, Math.round(w / h * 0.85)));
        loops = [];
        count = 0;
        for (var s = 0; s < seeds; s++) {
          var cx = w * (s + 0.5) / seeds + (Math.random() - 0.5) * w * 0.06 / seeds;
          var cy = h * (0.45 + Math.random() * 0.1);
          var n = 14, r = R * 1.1, pts = [];
          for (var i = 0; i < n; i++) {
            var a = i / n * Math.PI * 2;
            pts.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r });
          }
          loops.push(pts);
          count += n;
        }
        phase = 'grow';
        fade = 1;
      }

      // One simulation step: pull to neighbours, push from crowds, insert points.
      function step() {
        var cell = R, gc = Math.ceil(w / cell) + 1, grid = {};
        loops.forEach(function (pts) {
          pts.forEach(function (p) {
            var k = Math.floor(p.y / cell) * gc + Math.floor(p.x / cell);
            (grid[k] || (grid[k] = [])).push(p);
            p.fx = 0; p.fy = 0;
          });
        });
        var edge = R * 0.5, margin = R * 0.8;
        loops.forEach(function (pts) {
          var n = pts.length;
          for (var i = 0; i < n; i++) {
            var p = pts[i], a = pts[(i + n - 1) % n], b = pts[(i + 1) % n];
            // Smooth towards the midpoint of its neighbours.
            p.fx += ((a.x + b.x) / 2 - p.x) * 0.35;
            p.fy += ((a.y + b.y) / 2 - p.y) * 0.35;
            // Push away from anything else close by.
            var gx = Math.floor(p.x / cell), gy = Math.floor(p.y / cell);
            for (var yy = gy - 1; yy <= gy + 1; yy++) {
              for (var xx = gx - 1; xx <= gx + 1; xx++) {
                var list = grid[yy * gc + xx];
                if (!list) continue;
                for (var j = 0; j < list.length; j++) {
                  var o = list[j];
                  if (o === p || o === a || o === b) continue;
                  var dx = p.x - o.x, dy = p.y - o.y, dd = dx * dx + dy * dy;
                  if (dd > R * R || dd < 1e-6) continue;
                  var dist = Math.sqrt(dd), f = (R - dist) / R * 0.5;
                  p.fx += dx / dist * f * R * 0.25;
                  p.fy += dy / dist * f * R * 0.25;
                }
              }
            }
            // Stay inside the box.
            if (p.x < margin) p.fx += (margin - p.x) * 0.5;
            if (p.x > w - margin) p.fx -= (p.x - w + margin) * 0.5;
            if (p.y < margin) p.fy += (margin - p.y) * 0.5;
            if (p.y > h - margin) p.fy -= (p.y - h + margin) * 0.5;
          }
        });
        loops.forEach(function (pts) {
          pts.forEach(function (p) {
            var m = Math.sqrt(p.fx * p.fx + p.fy * p.fy), cap = R * 0.3;
            if (m > cap) { p.fx *= cap / m; p.fy *= cap / m; }
            p.x += p.fx;
            p.y += p.fy;
          });
        });
        // Grow: split edges that have stretched, and a few at random.
        if (count >= maxPts) return;
        loops.forEach(function (pts) {
          for (var i = pts.length - 1; i >= 0 && count < maxPts; i--) {
            var p = pts[i], q = pts[(i + 1) % pts.length];
            var dx = q.x - p.x, dy = q.y - p.y, len = Math.sqrt(dx * dx + dy * dy);
            if (len > edge || Math.random() < 0.012) {
              pts.splice(i + 1, 0, { x: p.x + dx / 2 + (Math.random() - 0.5) * 0.2, y: p.y + dy / 2 + (Math.random() - 0.5) * 0.2 });
              count++;
            }
          }
        });
      }

      function path(pts) {
        var n = pts.length;
        ctx.beginPath();
        ctx.moveTo((pts[n - 1].x + pts[0].x) / 2, (pts[n - 1].y + pts[0].y) / 2);
        for (var i = 0; i < n; i++) {
          var p = pts[i], q = pts[(i + 1) % n];
          ctx.quadraticCurveTo(p.x, p.y, (p.x + q.x) / 2, (p.y + q.y) / 2);
        }
        ctx.closePath();
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.globalAlpha = fade;
        ctx.lineJoin = 'round';
        loops.forEach(function (pts) {
          path(pts);
          ctx.fillStyle = colors[0];
          ctx.fill();
          ctx.lineWidth = Math.max(1.5, R * 0.32);
          ctx.strokeStyle = colors[1] || colors[0];
          ctx.stroke();
          ctx.lineWidth = Math.max(0.8, R * 0.09);
          ctx.strokeStyle = colors[2] || '#fff';
          ctx.globalAlpha = fade * 0.75;
          ctx.stroke();
          ctx.globalAlpha = fade;
        });
        ctx.globalAlpha = 1;
      }

      function tick(dt) {
        clock += dt;
        if (phase === 'grow') {
          var steps = Math.max(1, Math.round(2 * speed * dt / 16));
          for (var i = 0; i < steps; i++) step();
          if (count >= maxPts) { phase = 'settle'; phaseAt = clock; }
        } else if (phase === 'settle') {
          step();
          if (clock - phaseAt > 1200) { 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) / 700);
          if (fade === 0) start();
        }
      }

      function still() {
        start();
        for (var i = 0; i < 4000 && count < maxPts; i++) step();
        for (var j = 0; j < 60; j++) step();
        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-coral-growth]').forEach(function (el) {
        if (el._crl) return;
        el._crl = true;
        build(el);
      });
    }

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