Skip to content
2D

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.

Loading preview…

Usage

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { FlowField } from "@/components/flowfield"; <FlowField />

Customise

900

The main cost knob. Each one is a line segment in a single batched path.

0.9
1

Higher swirls tighter; lower drifts in long sweeps.

0.92

Near 1 leaves long smoke. Below ~0.8 it reads as separate dots.

7

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.