L-system Plant
A plant grows from a few rewriting rules. A short string such as F[+F]F[-F]F is rewritten into itself a few times, then read by a turtle that draws a stem for each F, turns at + and −, and branches at the brackets. Stems grow out from the root, leaves and flowers open at the tips, and the plant sways in a light breeze. Tap to grow another.
Tap to grow another
How to use it
- Paste the snippet just before
</head>. - Add
data-l-system-plantto a box with a height.
Options
Add any of these attributes to the same element.
data-plant | fern, bush, weed, tree or sprig. Default fern. A comma-separated list takes turns. |
data-loop | Hold the grown plant for a moment, then grow the next one. |
data-duration | Growing time. Default 5000 ms. |
data-color | Colour of the thick stems. Default #6b4f3a. |
data-leaf | Leaf colour, which the thin stems fade towards. Default #4d9b3a. |
data-flower | Flower colour, or none. Default #f472b6. |
data-sway="false" | Keep still once grown. |
Turns and stem lengths are jittered a little, so no two plants are quite alike. It’s drawn on a transparent canvas and paused when off screen. With reduced motion the plant appears fully grown.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// L-system Plant: a plant grows from a handful of rewriting rules. A
// short string such as "F[+F]F[-F]F" is rewritten into itself a few
// times, then read by a turtle: F draws a stem, + and - turn, [ and ]
// start and finish a branch. The stems grow out branch by branch from
// the root, leaves and flowers open at the tips, and the finished plant
// sways in a light breeze. Every plant is a little different. Tap to grow
// another. Drawn on a transparent canvas and paused when off screen.
// Usage: <div data-l-system-plant style="height:360px"></div>
// Optional: data-plant ("fern", "bush", "weed", "tree" or "sprig";
// default fern; a comma-separated list takes turns), data-loop (hold the
// grown plant, then grow the next one), data-duration (growing time in ms,
// default 5000), data-color (stem, default #6b4f3a), data-leaf (default
// #4d9b3a), data-flower (default #f472b6, or "none"), data-sway="false"
// (keep still once grown).
var DEFAULTS = { plant: 'fern', duration: 5000, color: '#6b4f3a', leaf: '#4d9b3a', flower: '#f472b6', hold: 2600 };
var PLANTS = {
fern: { axiom: 'X', rules: { X: 'F+[[X]-X]-F[-FX]+X', F: 'FF' }, n: 5, angle: 25 },
bush: { axiom: 'F', rules: { F: 'FF+[+F-F-F]-[-F+F+F]' }, n: 4, angle: 22.5 },
weed: { axiom: 'F', rules: { F: 'F[+F]F[-F]F' }, n: 4, angle: 25.7 },
tree: { axiom: 'X', rules: { X: 'F[+X][-X]FX', F: 'FF' }, n: 6, angle: 25.7 },
sprig: { axiom: 'X', rules: { X: 'F[+X]F[-X]+X', F: 'FF' }, n: 6, angle: 20 }
};
var BINS = 10;
var css =
'[data-l-system-plant]{position:relative;isolation:isolate;overflow:hidden}' +
'.lsy-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 rgb(hex) {
var n = parseInt(hex.replace('#', ''), 16);
return [n >> 16, n >> 8 & 255, n & 255];
}
// Rewrite the axiom n times.
function expand(p) {
var s = p.axiom;
for (var i = 0; i < p.n; i++) {
var out = '';
for (var j = 0; j < s.length; j++) out += p.rules[s.charAt(j)] || s.charAt(j);
s = out;
}
return s;
}
// Read the string with a turtle, into stems that each know their parent.
function grow(p) {
var s = expand(p), segs = [], stack = [];
var mirror = Math.random() < 0.5 ? -1 : 1;
var turn = p.angle * Math.PI / 180;
var parent = -1, ang = -Math.PI / 2, depth = 0;
for (var i = 0; i < s.length; i++) {
var c = s.charAt(i);
if (c === 'F') {
var pa = parent >= 0 ? segs[parent].ang : -Math.PI / 2;
var len = 0.85 + Math.random() * 0.3;
segs.push({
parent: parent, rel: ang - pa, ang: ang, len: len, depth: depth,
start: parent >= 0 ? segs[parent].start + segs[parent].len : 0,
tips: 0, kids: 0, leaf: Math.random(), bloom: Math.random()
});
if (parent >= 0) segs[parent].kids++;
parent = segs.length - 1;
} else if (c === '+' || c === '-') {
ang += (c === '+' ? 1 : -1) * mirror * turn * (0.8 + Math.random() * 0.4);
} else if (c === '[') {
stack.push([parent, ang, depth]);
depth++;
} else if (c === ']') {
var st = stack.pop();
parent = st[0]; ang = st[1]; depth = st[2];
}
}
// How many tips hang off each stem: thick stems carry many.
for (i = segs.length - 1; i >= 0; i--) {
if (!segs[i].kids) segs[i].tips = 1;
if (segs[i].parent >= 0) segs[segs[i].parent].tips += segs[i].tips;
}
return segs;
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'lsy-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var names = (d.plant || DEFAULTS.plant).split(',').map(function (p) { return p.trim(); })
.filter(function (p) { return PLANTS[p]; });
if (!names.length) names = [DEFAULTS.plant];
var which = 0;
var duration = +d.duration || DEFAULTS.duration;
var stem = rgb(d.color || DEFAULTS.color), leafRGB = rgb(d.leaf || DEFAULTS.leaf);
var leafColor = d.leaf || DEFAULTS.leaf;
var flower = (d.flower || DEFAULTS.flower) === 'none' ? null : d.flower || DEFAULTS.flower;
var sway = d.sway !== 'false';
var loop = d.loop !== undefined && d.loop !== 'false';
var w = 0, h = 0, dpr = 1;
var segs = [], maxDist = 1, maxTips = 1, X0, Y0, X1, Y1, A, scale = 1, rootX = 0, rootY = 0;
var t = 0, age = 0, waiting = false, binColors = [];
for (var b = 0; b < BINS; b++) {
var k = 1 - b / (BINS - 1);
binColors.push('rgb(' + Math.round(stem[0] + (leafRGB[0] - stem[0]) * k * 0.8) + ',' +
Math.round(stem[1] + (leafRGB[1] - stem[1]) * k * 0.8) + ',' + Math.round(stem[2] + (leafRGB[2] - stem[2]) * k * 0.8) + ')');
}
// Work out every stem's ends, given how far the growth has reached.
function pose(windy) {
for (var i = 0; i < segs.length; i++) {
var s = segs[i], p = s.parent;
var a = (p >= 0 ? A[p] : -Math.PI / 2) + s.rel;
// Thin stems bend in the breeze; thick ones barely move.
if (windy) a += Math.sin(t / 1300 + s.start * 0.05) * 0.035 / (1 + s.tips * 0.6) + Math.sin(t / 2100) * 0.004;
A[i] = a;
X0[i] = p >= 0 ? X1[p] : 0;
Y0[i] = p >= 0 ? Y1[p] : 0;
X1[i] = X0[i] + Math.cos(a) * s.len;
Y1[i] = Y0[i] + Math.sin(a) * s.len;
}
}
// Grow a new plant, and fit it into the box.
function plant() {
segs = grow(PLANTS[names[which % names.length]]);
var n = segs.length;
X0 = new Float32Array(n); Y0 = new Float32Array(n); X1 = new Float32Array(n); Y1 = new Float32Array(n); A = new Float32Array(n);
maxDist = 1; maxTips = 1;
segs.forEach(function (s) { maxDist = Math.max(maxDist, s.start + s.len); maxTips = Math.max(maxTips, s.tips); });
age = 0;
fit();
}
function fit() {
if (!segs.length) return;
pose(false);
var minX = 0, maxX = 0, minY = 0;
for (var i = 0; i < segs.length; i++) {
minX = Math.min(minX, X1[i]); maxX = Math.max(maxX, X1[i]); minY = Math.min(minY, Y1[i]);
}
var pad = Math.max(10, Math.min(w, h) * 0.06);
scale = Math.min((w - pad * 2) / Math.max(1, maxX - minX), (h - pad * 2) / Math.max(1, -minY));
rootX = w / 2 - (minX + maxX) / 2 * scale;
rootY = h - pad;
}
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);
fit();
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
pose(sway);
var p = Math.min(1, age / duration), front = maxDist * (1 - Math.pow(1 - p, 2)) * 1.02;
var base = Math.max(1.8, Math.min(7, Math.min(w, h) / 60));
ctx.lineCap = 'round';
// Stems, thickest first, in a few batches of similar width.
for (var b = BINS - 1; b >= 0; b--) {
ctx.beginPath();
var any = false;
for (var i = 0; i < segs.length; i++) {
var s = segs[i], f = (front - s.start) / s.len;
if (f <= 0) continue;
var bin = Math.min(BINS - 1, Math.floor(Math.sqrt(s.tips / maxTips) * BINS));
if (bin !== b) continue;
if (f > 1) f = 1;
var x0 = rootX + X0[i] * scale, y0 = rootY + Y0[i] * scale;
ctx.moveTo(x0, y0);
ctx.lineTo(x0 + (X1[i] - X0[i]) * scale * f, y0 + (Y1[i] - Y0[i]) * scale * f);
any = true;
}
if (!any) continue;
ctx.strokeStyle = binColors[b];
ctx.lineWidth = Math.max(0.7, base * Math.pow((b + 1) / BINS, 1.4));
ctx.stroke();
}
// Leaves, then flowers, opening at the tips once their stem is grown.
var size = Math.max(2.4, Math.min(6, scale * 2.2));
for (var pass = 0; pass < 2; pass++) {
if (pass === 1 && !flower) break;
ctx.fillStyle = pass ? flower : leafColor;
for (i = 0; i < segs.length; i++) {
s = segs[i];
if (s.kids || (pass === 1) !== (!!flower && s.bloom < 0.08)) continue;
var open = (front - s.start - s.len) / (maxDist * 0.08);
if (open <= 0) continue;
open = Math.min(1, open);
var x = rootX + X1[i] * scale, y = rootY + Y1[i] * scale, a = A[i] + (s.leaf - 0.5) * 1.2;
var r = size * open * (pass ? 0.75 : 0.8 + s.leaf * 0.4);
ctx.globalAlpha = pass ? 1 : 0.85;
ctx.beginPath();
if (pass) {
// Five round petals.
for (var q = 0; q < 5; q++) {
var pa = a + q * Math.PI * 0.4;
ctx.moveTo(x + Math.cos(pa) * r * 0.7 + r * 0.45, y + Math.sin(pa) * r * 0.7);
ctx.arc(x + Math.cos(pa) * r * 0.7, y + Math.sin(pa) * r * 0.7, r * 0.45, 0, Math.PI * 2);
}
} else {
// A pointed leaf along the stem's direction.
var c = Math.cos(a), sn = Math.sin(a);
ctx.moveTo(x, y);
ctx.quadraticCurveTo(x + c * r - sn * r * 0.55, y + sn * r + c * r * 0.55, x + c * r * 1.9, y + sn * r * 1.9);
ctx.quadraticCurveTo(x + c * r + sn * r * 0.55, y + sn * r - c * r * 0.55, x, y);
}
ctx.fill();
}
}
ctx.globalAlpha = 1;
}
function next() {
if (waiting) return;
waiting = true;
anime.animate(canvas, {
opacity: 0, duration: 500, ease: 'inOut(2)',
onComplete: function () {
which++;
plant();
waiting = false;
anime.utils.set(canvas, { opacity: 1 });
}
});
}
resize();
plant();
if (reduced()) {
// The grown plant, standing still.
age = duration * 2;
sway = false;
draw();
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
el.addEventListener('pointerdown', next);
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;
t += dt;
age += dt;
if (loop && age > duration * 1.15 + DEFAULTS.hold) next();
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-l-system-plant]').forEach(function (el) {
if (el._lsy) return;
el._lsy = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-l-system-plant style="height: 360px"></div>
<!-- take turns: -->
<div data-l-system-plant data-plant="fern,tree,weed" data-loop style="height: 360px"></div>