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
- Paste the snippet just before
</head>. - Add
data-vein-growthto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-shape | leaf, or box to grow up from the bottom edge across the whole box. Default leaf. |
data-points | How many points to scatter. 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 | Hold 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.
<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><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>