Perlin flow field
Oct 12, 2026 · p5.js
Ken Perlin wrote his noise function in 1983 because computer graphics looked too obviously mathematical. Forty years later it is still the fastest route from "a blank canvas" to "something that looks alive."
Every particle here reads the same invisible field — a smooth, rolling value noise sampled at each point — and takes one small step along it. That is the whole organism. There is no plan, no destination, no collision; the movement you see is just the field made visible, one hairline at a time, until the paper remembers everywhere the current went.
Move your pointer through it: the field bends around you like water around a stone, then forgets.
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";
/**
* Perlin flow field — the quintessential creative-coding sketch: thousands
* of particles drifting through a noise field, each leaving a thin ink
* stroke. The pointer bends the field around itself. Drawn with p5.js in
* instance mode; see the code below the canvas on the sketch page — it is
* this exact file, baked in at build time.
*/
interface Particle {
x: number;
y: number;
green: boolean;
}
export default function FlowField() {
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 sketch = (p: p5) => {
let particles: Particle[] = [];
let pointer = { x: -9999, y: -9999 };
p.setup = () => {
const canvas = p.createCanvas(wrap.clientWidth, 480);
canvas.parent(wrap);
p.pixelDensity(Math.min(globalThis.devicePixelRatio ?? 1, 2));
p.background(paper);
p.strokeWeight(1);
particles = Array.from({ length: 1600 }, () => ({
x: p.random(p.width),
y: p.random(p.height),
green: p.random() < 0.05,
}));
if (reduced) {
// A still frame: march the field once, no animation.
for (let step = 0; step < 260; step++) stepParticles();
p.noLoop();
} else {
// Warm-up so the paper carries texture from the first paint.
for (let step = 0; step < 70; step++) stepParticles();
}
};
const angleAt = (x: number, y: number) => {
const base = p.noise(x * 0.0022, y * 0.0022, p.frameCount * 0.0015) *
p.TWO_PI * 2;
// The pointer bends the field radially around itself.
const dx = x - pointer.x, dy = y - pointer.y;
const d2 = dx * dx + dy * dy;
const push = d2 < 130 ** 2 ? (1 - Math.sqrt(d2) / 130) * 0.9 : 0;
return base + push * Math.atan2(dy, dx);
};
const stepParticles = () => {
for (const pt of particles) {
const a = angleAt(pt.x, pt.y);
const nx = pt.x + Math.cos(a) * 1.7;
const ny = pt.y + Math.sin(a) * 1.7;
// Low-alpha hex so strokes accumulate into ink-wash tones.
p.stroke(pt.green ? `${accent}78` : `${ink}34`);
p.line(pt.x, pt.y, nx, ny);
pt.x = nx;
pt.y = ny;
// Recycle drifted particles.
if (
pt.x < -4 || pt.x > p.width + 4 || pt.y < -4 ||
pt.y > p.height + 4
) {
pt.x = p.random(p.width);
pt.y = p.random(p.height);
}
}
};
p.draw = () => stepParticles();
p.windowResized = () => {
p.resizeCanvas(wrap.clientWidth, 480);
p.background(paper);
};
const onMove = (e: PointerEvent) => {
const rect = wrap.getBoundingClientRect();
pointer = { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
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);
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" />;
}