← Generative art

Fractal Tree

A tree grows from a single trunk. Every branch splits into two smaller ones, again and again, until blossoms open at the tips. Then it sways in gusts of wind: the twigs bend most and the trunk hardly moves. Tap to grow another.

Tap to grow another

How to use it

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

Options

Add any of these attributes to the same element.

data-depthHow many times the branches split, 3 to 13. Default 10.
data-angleSpread between each pair of branches, in degrees. Default 30.
data-colorTrunk colour. Default #4a3428.
data-leafTwig colour, which the branches fade towards. Default #a8577e.
data-blossomBlossom colour, or none. Default #f9a8d4.
data-windStrength of the wind, 0 for still. Default 1.
data-durationGrowing time. Default 3500 ms.
data-loopHold the grown tree for a few seconds, then grow a new one.

Every branch gets a slightly random length and turn, so no two trees are alike. It’s drawn on a transparent canvas and paused when off screen. With reduced motion the tree appears fully grown and still.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Fractal Tree: a tree grows from a single trunk. Every branch splits in
    // two smaller branches, again and again, until blossoms open at the tips.
    // The finished tree sways in gusts of wind: the twigs bend most, the
    // trunk hardly at all. Every tree is a little different. Tap to grow
    // another. Drawn on a transparent canvas and paused when off screen.
    // Usage: <div data-fractal-tree style="height:360px"></div>
    // Optional: data-depth (how many times the branches split, default 10),
    // data-angle (spread between two branches in degrees, default 30),
    // data-color (trunk, default #4a3428), data-leaf (twigs, default #a8577e),
    // data-blossom (default #f9a8d4, or "none"), data-wind (strength, 0 for
    // still, default 1), data-duration (growing time in ms, default 3500),
    // data-loop (hold the grown tree, then grow a new one).
    var DEFAULTS = { depth: 10, angle: 30, color: '#4a3428', leaf: '#a8577e', blossom: '#f9a8d4', wind: 1, duration: 3500, hold: 6000 };

    var css =
      '[data-fractal-tree]{position:relative;isolation:isolate;overflow:hidden}' +
      '.frt-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];
    }

    function mix(a, b, k) {
      return 'rgb(' + Math.round(a[0] + (b[0] - a[0]) * k) + ',' + Math.round(a[1] + (b[1] - a[1]) * k) + ',' + Math.round(a[2] + (b[2] - a[2]) * k) + ')';
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'frt-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var depth = Math.max(3, Math.min(13, +d.depth || DEFAULTS.depth));
      var spread = (d.angle ? +d.angle : DEFAULTS.angle) * Math.PI / 180;
      var wind = d.wind !== undefined ? +d.wind : DEFAULTS.wind;
      var duration = +d.duration || DEFAULTS.duration;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var trunk = rgb(d.color || DEFAULTS.color), twig = rgb(d.leaf || DEFAULTS.leaf);
      var blossom = (d.blossom || DEFAULTS.blossom) === 'none' ? null : d.blossom || DEFAULTS.blossom;
      var colors = [];
      for (var i = 0; i <= depth; i++) colors.push(mix(trunk, twig, Math.pow(i / depth, 1.3)));
      var w = 0, h = 0, dpr = 1, unit = 1;
      // Each branch, numbered like a heap (children of i are 2i+1 and 2i+2),
      // gets its own random length, turn and bloom.
      var count = Math.pow(2, depth + 1) - 1;
      var lens = new Float32Array(count), turns = new Float32Array(count), blooms = new Float32Array(count);
      var t = 0, age = 0, waiting = false;
      var paths = [], tips = [];

      function seed() {
        var lean = (Math.random() - 0.5) * 0.12;
        for (var i = 0; i < count; i++) {
          lens[i] = 0.68 + Math.random() * 0.12;
          turns[i] = (Math.random() - 0.5) * 0.35;
          blooms[i] = Math.random();
        }
        lens[0] = 1;
        turns[0] = lean;
        age = 0;
      }

      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);
        // Trunk length, so the crown fits both ways.
        unit = Math.min(h * 0.26, w * 0.2);
      }

      // Walk the tree, collecting each depth's branches into one path.
      function walk(i, x, y, ang, len, lvl, grown, gust) {
        var f = grown - lvl;
        if (f <= 0) return;
        if (f > 1) f = 1;
        // Thin branches bend more in the wind.
        var a = ang + turns[i] + gust * (lvl + 1) * (lvl + 1) * 0.0022 + Math.sin(t / 900 + i * 0.7) * 0.012 * lvl * wind;
        var ex = x + Math.cos(a) * len * f, ey = y + Math.sin(a) * len * f;
        paths[lvl].push(x, y, ex, ey);
        if (lvl === depth) {
          if (f >= 1) tips.push(ex, ey, blooms[i], Math.min(1, grown - depth));
          return;
        }
        if (f < 1) return;
        walk(2 * i + 1, ex, ey, a - spread / 2, len * lens[2 * i + 1], lvl + 1, grown, gust);
        walk(2 * i + 2, ex, ey, a + spread / 2, len * lens[2 * i + 2], lvl + 1, grown, gust);
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, w, h);
        var p = Math.min(1, age / duration);
        var grown = (1 - Math.pow(1 - p, 1.6)) * (depth + 1.6);
        // Gusts: two slow waves and a quicker flutter on top.
        var gust = wind * (Math.sin(t / 2300) * 0.8 + Math.sin(t / 1100 + 1) * 0.35 + Math.sin(t / 3700) * 0.5);
        paths = [];
        for (var i = 0; i <= depth; i++) paths.push([]);
        tips = [];
        walk(0, w / 2, h - Math.max(6, h * 0.03), -Math.PI / 2, unit, 0, grown, gust);
        ctx.lineCap = 'round';
        var base = Math.max(3, unit / 9);
        for (i = 0; i <= depth; i++) {
          var seg = paths[i];
          if (!seg.length) continue;
          ctx.beginPath();
          for (var j = 0; j < seg.length; j += 4) {
            ctx.moveTo(seg[j], seg[j + 1]);
            ctx.lineTo(seg[j + 2], seg[j + 3]);
          }
          ctx.strokeStyle = colors[i];
          ctx.lineWidth = Math.max(0.6, base * Math.pow(0.68, i));
          ctx.stroke();
        }
        if (!blossom) return;
        // Blossoms open on about half the tips.
        ctx.fillStyle = blossom;
        var r = Math.max(1.4, Math.min(3.4, unit / 30));
        for (i = 0; i < tips.length; i += 4) {
          if (tips[i + 2] > 0.55) continue;
          var open = Math.max(0, Math.min(1, tips[i + 3] * 2.5 - tips[i + 2]));
          if (!open) continue;
          ctx.globalAlpha = 0.55 + tips[i + 2] * 0.6;
          ctx.beginPath();
          ctx.arc(tips[i], tips[i + 1], r * open * (0.7 + tips[i + 2]), 0, Math.PI * 2);
          ctx.fill();
        }
        ctx.globalAlpha = 1;
      }

      function next() {
        if (waiting) return;
        waiting = true;
        anime.animate(canvas, {
          opacity: 0, duration: 500, ease: 'inOut(2)',
          onComplete: function () {
            seed();
            waiting = false;
            anime.utils.set(canvas, { opacity: 1 });
          }
        });
      }

      resize();
      seed();
      if (reduced()) {
        // The grown tree, standing still.
        age = duration * 3;
        wind = 0;
        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.3 + 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-fractal-tree]').forEach(function (el) {
        if (el._frt) return;
        el._frt = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-fractal-tree style="height: 360px"></div>

<!-- a new tree every few seconds: -->
<div data-fractal-tree data-loop style="height: 360px"></div>