← Generative art

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

  1. Paste the snippet just before </head>.
  2. Add data-l-system-plant to a box with a height.

Options

Add any of these attributes to the same element.

data-plantfern, bush, weed, tree or sprig. Default fern. A comma-separated list takes turns.
data-loopHold the grown plant for a moment, then grow the next one.
data-durationGrowing time. Default 5000 ms.
data-colorColour of the thick stems. Default #6b4f3a.
data-leafLeaf colour, which the thin stems fade towards. Default #4d9b3a.
data-flowerFlower 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.

1 · Snippet
<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>
2 · Your HTML
<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>