Flow Field
A few hundred particles steered by a noise field, leaving trails behind them. The field is sampled from a coarse lattice built once at mount rather than evaluated per particle per frame, and trails come from painting a translucent fill over the canvas instead of retaining path history. Seeded, so every visitor and every build gets the identical composition — a background that reshuffles on reload is a liability in screenshots and visual regression tests.
Usage
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { FlowField } from "@/components/flowfield"; <FlowField />Customise
The main cost knob. Each one is a line segment in a single batched path.
Higher swirls tighter; lower drifts in long sweeps.
Near 1 leaves long smoke. Below ~0.8 it reads as separate dots.
Changes the composition and nothing else. Same seed, same picture, forever.
The component
components/craft/2d/FlowField.tsx
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useEffect, useRef } from "react"; /** * Particles drifting through a noise field, trailing ink behind them. * * Two decisions make this cheap enough to sit behind real content: * * The field is sampled from a coarse grid computed once at mount, not * evaluated per particle per frame. Angles vary slowly across space, so a * 24px lattice is visually identical to per-pixel sampling and turns a few * thousand noise evaluations per frame into a couple of array lookups. * * Trails come from painting a translucent rectangle over the canvas each * frame instead of clearing it. The fade is free — the GPU composites it — * and it means each particle draws one short line rather than the browser * retaining a path history. * * The seed is fixed, so every visitor and every build gets the same * composition. A background that reshuffles on reload is a liability in * screenshots and visual regression tests. */ /** Deterministic PRNG — mulberry32. Same seed, same picture, every time. */function rng(seed: number) { return () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };} /** Value noise: cheap, smooth, and plenty for steering a flow field. */function makeNoise(random: () => number) { const size = 256; const table = Array.from({ length: size }, () => random()); const smooth = (t: number) => t * t * (3 - 2 * t); return (x: number, y: number) => { const xi = Math.floor(x); const yi = Math.floor(y); const xf = smooth(x - xi); const yf = smooth(y - yi); const at = (a: number, b: number) => table[(((a & 255) + ((b & 255) << 4)) | 0) % size]; const top = at(xi, yi) + xf * (at(xi + 1, yi) - at(xi, yi)); const bottom = at(xi, yi + 1) + xf * (at(xi + 1, yi + 1) - at(xi, yi + 1)); return top + yf * (bottom - top); };} export interface FlowFieldProps { /** How many particles. The main cost knob — see the note on the page. */ count?: number; color?: string; background?: string; /** Pixels travelled per frame. */ speed?: number; /** Higher swirls tighter; lower drifts in long sweeps. */ turbulence?: number; /** Trail persistence, 0–1. Near 1 leaves long smoke; low clears fast. */ trail?: number; /** Changes the composition without changing anything else. */ seed?: number; still?: boolean; className?: string;} export function FlowField({ count = 900, color = "#c3f53c", background = "#0a0b0e", speed = 0.9, turbulence = 1, trail = 0.92, seed = 7, still = false, className = "",}: FlowFieldProps) { const ref = useRef<HTMLCanvasElement>(null); useEffect(() => { const canvas = ref.current; if (!canvas) return; const ctx = canvas.getContext("2d", { alpha: false }); if (!ctx) return; const random = rng(seed); const noise = makeNoise(random); const reduced = still || (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches); // Capped at 2×: trails are soft, so the third device pixel buys nothing // and triples fill cost on phones. const dpr = Math.min(devicePixelRatio || 1, 2); let width = 0; let height = 0; const CELL = 24; let field: Float32Array = new Float32Array(0); let cols = 0; const buildField = () => { cols = Math.ceil(width / CELL) + 1; const rows = Math.ceil(height / CELL) + 1; field = new Float32Array(cols * rows); for (let y = 0; y < rows; y += 1) { for (let x = 0; x < cols; x += 1) { const n = noise((x * turbulence) / 6, (y * turbulence) / 6); field[y * cols + x] = n * Math.PI * 4; } } }; const particles = Array.from({ length: count }, () => ({ x: random(), y: random(), life: random() * 200, })); const resize = () => { const w = Math.floor(canvas.clientWidth * dpr); const h = Math.floor(canvas.clientHeight * dpr); // Ignore a zero measurement rather than committing a 0×0 buffer — see // the same guard in MeshGradient. A stuck zero-size canvas is the // "blank until you reload" failure. if (!w || !h) return false; if (w === width && h === height) return false; width = w; height = h; canvas.width = w; canvas.height = h; buildField(); ctx.fillStyle = background; ctx.fillRect(0, 0, width, height); return true; }; resize(); const step = () => { // Fade rather than clear: this IS the trail. ctx.fillStyle = background; ctx.globalAlpha = 1 - trail; ctx.fillRect(0, 0, width, height); ctx.globalAlpha = 0.75; ctx.strokeStyle = color; ctx.lineWidth = dpr; ctx.beginPath(); for (const p of particles) { const px = p.x * width; const py = p.y * height; const cx = Math.min(cols - 1, Math.max(0, Math.floor(px / (CELL * dpr)))); const cy = Math.max(0, Math.floor(py / (CELL * dpr))); const angle = field[cy * cols + cx] ?? 0; const nx = px + Math.cos(angle) * speed * dpr; const ny = py + Math.sin(angle) * speed * dpr; ctx.moveTo(px, py); ctx.lineTo(nx, ny); p.x = nx / width; p.y = ny / height; p.life -= 1; // Recycled rather than removed, so the array length never changes // and the loop stays monomorphic. if (p.life <= 0 || p.x < 0 || p.x > 1 || p.y < 0 || p.y > 1) { p.x = random(); p.y = random(); p.life = 120 + random() * 160; } } // One stroke for every particle, not one per particle: batching the // path is the difference between a few hundred draw calls and one. ctx.stroke(); ctx.globalAlpha = 1; }; let frame = 0; const loop = () => { resize(); step(); frame = requestAnimationFrame(loop); }; if (reduced) { // Settled composition rather than a blank panel: the same simulation, // fast-forwarded once, then left alone. for (let i = 0; i < 220; i += 1) step(); } else { frame = requestAnimationFrame(loop); } const observer = new ResizeObserver(() => { if (!reduced) return; if (resize()) for (let i = 0; i < 220; i += 1) step(); }); observer.observe(canvas); return () => { cancelAnimationFrame(frame); observer.disconnect(); }; }, [count, color, background, speed, turbulence, trail, seed, still]); return ( <canvas ref={ref} aria-hidden className={`block h-full w-full ${className}`} /> );} One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.
What it costs
- Weight
+3.4KB gzipped · no new dependencies
- Runs on
Main thread
Runs JavaScript every frame. Heavy work here competes with your own code and with scrolling — watch the per-frame budget below.
- Reduced motion
Runs the simulation forward 220 steps once, then stops — you get the settled composition as a still image rather than an empty panel.
- Accessibility
- Canvas is aria-hidden; purely decorative.
- Adds no focusable elements.
- Deterministic output means automated visual diffs stay stable.
- Watch out
- This is the one component here that runs JavaScript every frame. At 900 particles it is around 0.3ms, but it competes with your own code.
- Canvas 2D fill rate is the real limit on large viewports — the trail repaints the full area each frame.