← Generative art

Vein Growth

Points are scattered across a leaf, and veins grow out from the stalk to reach them. Each point tugs on the nearest vein tip, every tip grows towards the pull it feels, and a point is used up once a vein arrives. The branches thicken as more grow beyond them, just like the veins of a real leaf.

How to use it

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

Options

Add any of these attributes to the same element.

data-shapeleaf, or box to grow up from the bottom edge across the whole box. Default leaf.
data-pointsHow many points to scatter. Default scaled to the area, 150 to 1400.
data-colorsLeaf, vein and point colours. Default #4d7c0f,#ecfccb,#bef264.
data-speedSpeed multiplier. Default 1.
data-loopHold the grown leaf, fade it and grow another.

It’s the “space colonisation” algorithm, which is also used to grow trees and roots in games and films. The leaf lies on its side in a wide box. It’s paused when off screen, and with reduced motion it shows the grown veins.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Vein Growth: points are scattered across a leaf, and veins grow out
    // from the stalk towards them. Each step, every point tugs on the vein
    // tip nearest to it; the tips grow towards the average of their tugs and
    // a point is used up once a vein reaches it. The branches thicken as more
    // grow beyond them, just like real leaf veins. This is the "space
    // colonisation" algorithm. With data-loop it holds, fades and grows a new
    // leaf.
    // Usage: <div data-vein-growth style="height:360px;background:#14261a"></div>
    // Optional: data-shape ("leaf" or "box"; box grows up from the bottom
    // edge across the whole box, default leaf), data-points (how many points,
    // default scaled to the area, 150 to 1400), data-colors (leaf, vein and
    // point colours, default "#4d7c0f,#ecfccb,#bef264"), data-speed (speed
    // multiplier, default 1), data-loop (fade and grow again).
    var DEFAULTS = { colors: '#4d7c0f,#ecfccb,#bef264', speed: 1, hold: 2600 };

    var css =
      '[data-vein-growth]{position:relative;isolation:isolate;overflow:hidden}' +
      '.vng-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 = 'vng-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 box = d.shape === 'box';
      var loop = d.loop !== undefined && d.loop !== 'false';
      var w = 0, h = 0, dpr = 1, seg = 5, reachR = 40, killR = 8;
      // The leaf: base at (bx, by), pointing along (ux, uy), length L, width W.
      var stale = 0, leaf = null, points = [], nodes = [], clock = 0, phase = 'grow', phaseAt = 0, fade = 1, owed = 0, maxRad = 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);
        return true;
      }

      // Half the leaf's width at distance u (0 at the stalk, 1 at the tip).
      function halfWidth(u) {
        if (u <= 0 || u >= 1) return 0;
        return leaf.W / 2 * Math.pow(Math.sin(Math.PI * Math.pow(u, 0.8)), 0.85);
      }

      function inside(x, y) {
        if (box) return x > 4 && x < w - 4 && y > 4 && y < h - 4;
        var dx = x - leaf.bx, dy = y - leaf.by;
        var u = (dx * leaf.ux + dy * leaf.uy) / leaf.L, v = -dx * leaf.uy + dy * leaf.ux;
        return Math.abs(v) < halfWidth(u) - 3;
      }

      function start() {
        if (box) {
          leaf = { bx: w / 2, by: h, ux: 0, uy: -1, L: h, W: w };
        } else if (w > h * 1.25) {
          // Wide box: the leaf lies on its side, tilted a touch.
          var L = Math.min(w * 0.86, h * 2.6), a = -0.12;
          leaf = { L: L, W: Math.min(h * 0.86, L * 0.5), ux: Math.cos(a), uy: Math.sin(a) };
          leaf.bx = w / 2 - leaf.ux * L / 2;
          leaf.by = h / 2 - leaf.uy * L / 2;
        } else {
          var L2 = h * 0.9, b = -Math.PI / 2 + 0.18;
          leaf = { L: L2, W: Math.min(w * 0.82, L2 * 0.58), ux: Math.cos(b), uy: Math.sin(b) };
          leaf.bx = w / 2 - leaf.ux * L2 / 2;
          leaf.by = h / 2 - leaf.uy * L2 / 2;
        }
        var area = box ? w * h : Math.PI * leaf.L * leaf.W / 4.6;
        var count = +d.points || Math.max(150, Math.min(1400, Math.round(area / 110)));
        seg = Math.max(3, Math.min(6, Math.sqrt(area / count) * 0.45));
        reachR = seg * 14;
        killR = seg * 1.8;
        points = [];
        for (var t = 0; points.length < count && t < count * 40; t++) {
          var x = Math.random() * w, y = Math.random() * h;
          if (inside(x, y)) points.push({ x: x, y: y, gone: -1 });
        }
        // The stalk: a single root node, plus a few in a line along the midrib.
        nodes = [];
        var roots = box ? 3 : 1;
        for (var r = 0; r < roots; r++) {
          var rx = box ? w * (r + 0.5) / roots : leaf.bx, ry = box ? h - 1 : leaf.by;
          nodes.push({ x: rx, y: ry, parent: -1, born: 0, kids: [], rad: 1, dx: leaf.ux, dy: leaf.uy });
        }
        clock = 0;
        owed = 0;
        phase = 'grow';
        fade = 1;
        maxRad = 1;
        stale = 0;
      }

      // One round of growth. Returns false once nothing can grow any more.
      function grow() {
        var cell = reachR, gcols = Math.ceil(w / cell) + 1, grid = {};
        nodes.forEach(function (n, i) {
          var k = Math.floor(n.y / cell) * gcols + Math.floor(n.x / cell);
          (grid[k] || (grid[k] = [])).push(i);
        });
        var pull = {};
        var live = 0;
        points.forEach(function (p) {
          if (p.gone >= 0) return;
          live++;
          var gx = Math.floor(p.x / cell), gy = Math.floor(p.y / cell), best = -1, bd = reachR * reachR;
          for (var yy = gy - 1; yy <= gy + 1; yy++) {
            for (var xx = gx - 1; xx <= gx + 1; xx++) {
              var list = grid[yy * gcols + xx];
              if (!list) continue;
              for (var j = 0; j < list.length; j++) {
                var n = nodes[list[j]], dx = p.x - n.x, dy = p.y - n.y, dd = dx * dx + dy * dy;
                if (dd < bd) { bd = dd; best = list[j]; }
              }
            }
          }
          if (best < 0) return;
          if (bd < killR * killR) { p.gone = clock; return; }
          var dist = Math.sqrt(bd);
          var f = pull[best] || (pull[best] = { x: 0, y: 0 });
          f.x += (p.x - nodes[best].x) / dist;
          f.y += (p.y - nodes[best].y) / dist;
        });
        var keys = Object.keys(pull);
        if (!keys.length) {
          // Nothing in reach yet: push the stalk tips on along the leaf.
          if (!live) return false;
          var tips = nodes.filter(function (n) { return n.kids.length === 0; });
          if (nodes.length > 80) return false;
          tips.forEach(function (n) { add(nodes.indexOf(n), n.dx, n.dy); });
          return true;
        }
        var added = 0;
        keys.forEach(function (k) {
          var f = pull[k], n = nodes[+k];
          // A little bias to keep going the same way, so veins flow smoothly.
          var x = f.x + n.dx * 0.6, y = f.y + n.dy * 0.6, m = Math.sqrt(x * x + y * y);
          if (m < 1e-6) return;
          if (add(+k, x / m, y / m)) added++;
        });
        // Stop once the veins can't get any closer to what's left.
        stale = added ? 0 : stale + 1;
        return stale < 3;
      }

      function add(i, ux, uy) {
        var p = nodes[i];
        var x = p.x + ux * seg, y = p.y + uy * seg;
        // Don't stack a new node right on top of a sibling.
        for (var k = 0; k < p.kids.length; k++) {
          var o = nodes[p.kids[k]];
          if (Math.abs(o.x - x) < seg * 0.2 && Math.abs(o.y - y) < seg * 0.2) return false;
        }
        nodes.push({ x: x, y: y, parent: i, born: clock, kids: [], rad: 1, dx: ux, dy: uy });
        p.kids.push(nodes.length - 1);
        // Pipe model: every vein below gets a little thicker.
        var j = i, r = 1;
        while (j >= 0) {
          nodes[j].rad += 1;
          if (nodes[j].rad > maxRad) maxRad = nodes[j].rad;
          j = nodes[j].parent;
          if (++r > 2000) break;
        }
        return true;
      }

      function leafPath() {
        ctx.beginPath();
        var n = 60;
        for (var s = 0; s <= n * 2; s++) {
          var side = s <= n ? 1 : -1, u = s <= n ? s / n : (2 * n - s) / n;
          var hw = halfWidth(u) * side;
          var x = leaf.bx + leaf.ux * u * leaf.L - leaf.uy * hw;
          var y = leaf.by + leaf.uy * u * leaf.L + leaf.ux * hw;
          if (s === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
        }
        ctx.closePath();
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        ctx.globalAlpha = fade;
        if (!box) {
          // Stalk and leaf.
          ctx.strokeStyle = colors[0];
          ctx.lineCap = 'round';
          ctx.lineWidth = Math.max(3, seg * 0.9);
          ctx.beginPath();
          ctx.moveTo(leaf.bx, leaf.by);
          ctx.lineTo(leaf.bx - leaf.ux * leaf.L * 0.1, leaf.by - leaf.uy * leaf.L * 0.1);
          ctx.stroke();
          leafPath();
          var g = ctx.createLinearGradient(leaf.bx, leaf.by, leaf.bx + leaf.ux * leaf.L, leaf.by + leaf.uy * leaf.L);
          g.addColorStop(0, colors[0]);
          g.addColorStop(1, '#65a30d');
          ctx.fillStyle = g;
          ctx.fill();
        }
        // Points still waiting, and ones just reached (a quick flash).
        ctx.fillStyle = colors[2] || colors[1];
        points.forEach(function (p) {
          var a = p.gone < 0 ? 0.55 : Math.max(0, 1 - (clock - p.gone) / 400);
          if (a <= 0) return;
          var r = p.gone < 0 ? 1.3 : 1.3 + (1 - a) * 2.5;
          ctx.globalAlpha = fade * a;
          ctx.beginPath();
          ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
          ctx.fill();
        });
        ctx.globalAlpha = fade;
        // Veins, thickest near the stalk.
        ctx.strokeStyle = colors[1] || '#fff';
        ctx.lineCap = 'round';
        var thick = Math.max(2.5, seg * 1.1);
        for (var i = 0; i < nodes.length; i++) {
          var n = nodes[i];
          if (n.parent < 0) continue;
          var p2 = nodes[n.parent];
          ctx.lineWidth = 0.7 + thick * Math.pow(n.rad / maxRad, 0.5);
          ctx.globalAlpha = fade * Math.min(1, 0.55 + n.rad / 6);
          ctx.beginPath();
          ctx.moveTo(p2.x, p2.y);
          ctx.lineTo(n.x, n.y);
          ctx.stroke();
        }
        ctx.globalAlpha = 1;
      }

      function tick(dt) {
        clock += dt;
        if (phase === 'grow') {
          owed += dt * speed * 0.03;
          while (owed >= 1) {
            owed--;
            if (!grow()) { phase = 'hold'; phaseAt = clock; break; }
          }
        } 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 < 3000 && grow(); i++) clock += 20;
        clock += 1000;
        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-vein-growth]').forEach(function (el) {
        if (el._vng) return;
        el._vng = true;
        build(el);
      });
    }

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

<!-- across the whole box: -->
<div data-vein-growth data-shape="box" style="height: 360px; background: #14261a"></div>