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.
The code
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" />;
}