Skip to content
Nap Joseph Calub

Recursive wind tree

Oct 12, 2026 · p5.js

A tree is a branch that branches. That sentence is the whole program: draw a line, shorten it, rotate left, recurse; rotate right, recurse. Repeat nine times and the line has become something that photosynthesizes, almost.

The branches here hang on damped springs rather than a fixed angle, so the tree does not just exist — it responds. Sweep your pointer across the canvas and you are the weather: the gust travels outward as the recursion deepens, thin branches swinging harder than the trunk, then everything settles back the way real canopies do, in stages.

It is the same wind that moves the poem on the home page. This sketch is what that wind looks like with bark on.

This is the exact file that is running above, baked in at build time — what you read is what you get.

import { useEffect, useRef } from "preact/hooks";
import type p5 from "p5";

/**
 * Recursive wind tree — a fractal tree whose branches hang on smoothed
 * springs. The pointer is the wind, exactly like the poem on the home
 * page: sweep across the canvas and the tree remembers the gust, then
 * settles. Depth-zero "leaves" carry the accent green.
 */

export default function WindTree() {
  const wrapRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const wrap = wrapRef.current;
    if (!wrap) return;
    const reduced = globalThis.matchMedia("(prefers-reduced-motion: reduce)")
      .matches;

    let cleanup = () => {};

    (async () => {
      const p5 = (await import("p5")).default;

      const css = getComputedStyle(document.documentElement);
      const paper = css.getPropertyValue("--color-paper").trim() || "#f6f5f1";
      const ink = css.getPropertyValue("--color-ink").trim() || "#17150f";
      const accent = css.getPropertyValue("--color-accent").trim() || "#0d6b57";

      const sketch = (p: p5) => {
        // Wind state: pointer velocity becomes a force on branch sway.
        let px = -9999, pvx = 0;
        let gust = 0;

        p.setup = () => {
          const canvas = p.createCanvas(wrap.clientWidth, 480);
          canvas.parent(wrap);
          p.pixelDensity(Math.min(globalThis.devicePixelRatio ?? 1, 2));
          p.frameRate(45);
        };

        const branch = (
          len: number,
          depth: number,
          sway: number,
          phase: number,
          t: number,
        ) => {
          p.stroke(depth === 0 ? accent : ink);
          p.strokeWeight(Math.max(depth * 1.1, 0.8));
          p.line(0, 0, 0, -len);
          p.translate(0, -len);

          if (depth === 0 || len < 6) {
            p.noStroke();
            p.fill(`${accent}dd`);
            p.circle(0, 0, 3.2);
            return;
          }

          const wobble = Math.sin(t * 1.4 + phase) * 0.012 + sway;
          p.push();
          p.rotate(0.42 + wobble);
          branch(len * 0.72, depth - 1, sway * 0.8, phase + 0.7, t);
          p.pop();
          p.push();
          p.rotate(-0.38 - wobble * 0.9);
          branch(len * 0.7, depth - 1, sway * 0.8, phase + 1.3, t);
          p.pop();
        };

        const draw = () => {
          p.background(paper);
          gust += (pvx * 0.004 - gust) * 0.08;
          pvx *= 0.92;

          p.push();
          p.translate(p.width / 2, p.height - 24);
          branch(112, 9, gust, 0, p.frameCount * 0.016);
          p.pop();
        };

        p.draw = () => draw();
        p.windowResized = () => p.resizeCanvas(wrap.clientWidth, 480);

        const onMove = (e: PointerEvent) => {
          const rect = wrap.getBoundingClientRect();
          const x = e.clientX - rect.left;
          const y = e.clientY - rect.top;
          // Only winds inside the canvas.
          if (
            x >= 0 && x <= rect.width && y >= 0 && y <= rect.height &&
            px > -9000
          ) {
            pvx = pvx * 0.6 + (x - px) * 0.4;
          }
          px = x;
        };
        globalThis.addEventListener("pointermove", onMove, { passive: true });

        if (reduced) {
          draw();
          p.noLoop();
        }

        cleanup = () => {
          globalThis.removeEventListener("pointermove", onMove);
          p.remove();
        };
      };

      new p5(sketch);
    })();

    return () => cleanup();
  }, []);

  return <div ref={wrapRef} class="w-full border border-line bg-paper" />;
}