Skip to content
Nap Joseph Calub

Phyllotaxis

Oct 12, 2026 · p5.js

Sunflowers do not plan their seed arrangement. Each seed simply grows at the golden angle, 137.508°, from the last one — the angle whose rotation never resonates with a full turn — and the spiral is what falls out. Nature does not draw spirals; it draws one dot at a time and the spiral is the consequence.

So here: dot n sits at angle n × 137.508° and radius √n. Nothing else. The dots at Fibonacci indices carry the green, because once you see the phyllotaxis you cannot stop seeing Fibonacci everywhere.

Bring your pointer near the center: you are bending the angle the way a gardener bends a stem, and the whole head rearranges.

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";

/**
 * Phyllotaxis — the golden-angle spiral that sunflowers, pinecones, and
 * succulents grow by. Every dot sits at angle n × 137.508° and radius
 * √n; the pattern emerges from two lines of arithmetic. Dot sizes breathe
 * with a slow sine; move the pointer close to the center to bend the angle
 * and hear the spiral rearrange itself.
 */

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

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

    let cleanup = () => {};
    let running = true;

    (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 GOLDEN = 137.508;

      const sketch = (p: p5) => {
        let bend = 0; // pointer-driven angle perturbation, smoothed
        let bendTarget = 0;

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

        const draw = () => {
          p.background(paper);
          p.push();
          p.translate(p.width / 2, p.height / 2);

          const t = p.frameCount * 0.012;
          const n = 900;
          // Fit the outermost dot (√n × scale) inside the shorter half-axis.
          const scale = (Math.min(p.width, p.height) / 2 - 26) / Math.sqrt(n);

          bend += (bendTarget - bend) * 0.06;
          p.rotate(t * 0.08);

          for (let i = 1; i <= n; i++) {
            const a = i * (GOLDEN + bend) * (Math.PI / 180);
            const r = scale * Math.sqrt(i);
            const breathe = 2.2 + Math.sin(t * 2 + i * 0.35) * 1.1;
            // Fibonacci-index dots carry the accent.
            const isFib = [
              1,
              2,
              3,
              5,
              8,
              13,
              21,
              34,
              55,
              89,
              144,
              233,
              377,
              610,
            ].includes(i);
            p.noStroke();
            p.fill(isFib ? accent : `${ink}c8`);
            p.circle(Math.cos(a) * r, Math.sin(a) * r, breathe);
          }
          p.pop();
        };

        p.draw = () => draw();

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

        const onMove = (e: PointerEvent) => {
          const rect = wrap.getBoundingClientRect();
          const dx = e.clientX - rect.left - rect.width / 2;
          const dy = e.clientY - rect.top - rect.height / 2;
          const near = Math.max(0, 1 - Math.hypot(dx, dy) / 220);
          bendTarget = near * 12;
        };
        globalThis.addEventListener("pointermove", onMove, { passive: true });

        const io = new IntersectionObserver(([entry]) => {
          if (reduced) return;
          if (entry.isIntersecting && !running) {
            p.loop();
            running = true;
          } else if (!entry.isIntersecting && running) {
            p.noLoop();
            running = false;
          }
        });
        io.observe(wrap);

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

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

      new p5(sketch);
    })();

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

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