Workspace / component / shaders

Flow Field Shader

Thousands of short strokes follow a curl-noise vector field, tracing currents like iron filings. Deterministic seed keeps captures reproducible.

Workspacecomponentshaders

paxfx add blocks/component-shaders-flow-fieldlocal only

Tier: medium. Reduced motion: Strokes render one settled frame.

"use client";

import { useEffect, useRef, type ReactNode } from "react";
import { cn, useReducedMotion } from "@pax-sh/blocks";

export interface FlowFieldShaderProps {
  /** Field and particle seed; the same seed always traces the same currents. */
  seed?: number;
  /** Number of strokes riding the field. Clamped to 500 to 3000. */
  strokes?: number;
  /** Drift speed multiplier. */
  speed?: number;
  className?: string;
  children?: ReactNode;
}

const LATTICE = 64;

function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), a | 1);
    t = (t + Math.imul(t ^ (t >>> 7), t | 61)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

type Rgb = readonly [number, number, number];

function readToken(el: HTMLElement, name: string, fallback: Rgb): Rgb {
  const raw = getComputedStyle(el).getPropertyValue(name).trim();
  if (raw.startsWith("#")) {
    const hex = raw.length === 4 ? [...raw.slice(1)].map((c) => c + c).join("") : raw.slice(1, 7);
    const n = Number.parseInt(hex, 16);
    if (!Number.isNaN(n)) return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  }
  const match = /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/.exec(raw);
  if (match) return [Number(match[1]), Number(match[2]), Number(match[3])];
  return fallback;
}

interface Particle {
  x: number;
  y: number;
  life: number;
  /** 0 = acid, 1 = paper, 2 = bright acid. */
  bucket: 0 | 1 | 2;
}

/**
 * Thousands of short strokes follow an angle-noise vector field, tracing
 * currents like iron filings around a magnet. Trails accumulate through a
 * translucent night fade each frame. All randomness is seeded, so the same
 * props reproduce the same drawing.
 */
export function FlowFieldShader({
  seed = 5,
  strokes = 2000,
  speed = 1,
  className,
  children,
}: FlowFieldShaderProps) {
  const reduced = useReducedMotion();
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const night = readToken(root, "--pax-night", [11, 13, 16]);
    const acid = readToken(root, "--pax-acid", [200, 255, 0]);
    const paper = readToken(root, "--pax-paper", [246, 242, 234]);
    const count = Math.min(3000, Math.max(500, Math.round(strokes)));

    const rand = mulberry32(seed);
    const lattice = new Float32Array(LATTICE * LATTICE);
    for (let i = 0; i < lattice.length; i += 1) lattice[i] = rand();

    const sample = (x: number, y: number): number => {
      const xi = Math.floor(x);
      const yi = Math.floor(y);
      const fx = x - xi;
      const fy = y - yi;
      const x0 = ((xi % LATTICE) + LATTICE) % LATTICE;
      const y0 = ((yi % LATTICE) + LATTICE) % LATTICE;
      const x1 = (x0 + 1) % LATTICE;
      const y1 = (y0 + 1) % LATTICE;
      const ux = fx * fx * (3 - 2 * fx);
      const uy = fy * fy * (3 - 2 * fy);
      const a = lattice[y0 * LATTICE + x0] ?? 0;
      const b = lattice[y0 * LATTICE + x1] ?? 0;
      const c = lattice[y1 * LATTICE + x0] ?? 0;
      const d = lattice[y1 * LATTICE + x1] ?? 0;
      const top = a + (b - a) * ux;
      const bottom = c + (d - c) * ux;
      return top + (bottom - top) * uy;
    };

    /** Angle of the vector field at a point, in radians. */
    const fieldAngle = (x: number, y: number, t: number): number => {
      const n =
        sample(x * 0.004 + t * 0.02, y * 0.004) * 0.7 +
        sample(x * 0.011, y * 0.011 + t * 0.014) * 0.3;
      return n * Math.PI * 4;
    };

    let width = 0;
    let height = 0;
    let elapsed = 0;
    let raf = 0;
    let running = false;
    let last = 0;
    let particles: Particle[] = [];

    const buckets: Array<{ style: string; width: number }> = [
      { style: `rgba(${acid[0]},${acid[1]},${acid[2]},0.30)`, width: 1 },
      { style: `rgba(${paper[0]},${paper[1]},${paper[2]},0.22)`, width: 1 },
      { style: `rgba(${acid[0]},${acid[1]},${acid[2]},0.75)`, width: 1.25 },
    ];

    const spawn = (rng: () => number): Particle => {
      const roll = rng();
      const bucket: Particle["bucket"] = roll < 0.8 ? 0 : roll < 0.94 ? 1 : 2;
      return {
        x: rng() * width,
        y: rng() * height,
        life: 40 + Math.floor(rng() * 120),
        bucket,
      };
    };

    const seedParticles = () => {
      const rng = mulberry32(seed ^ 0x9e3779b9);
      particles = Array.from({ length: count }, () => spawn(rng));
    };

    // Respawns during animation continue a second deterministic stream.
    const respawnRng = mulberry32(seed ^ 0x51ed2701);

    const step = (drawStep: number) => {
      for (let b = 0; b < 3; b += 1) {
        const bucket = buckets[b];
        if (!bucket) continue;
        ctx.beginPath();
        for (const p of particles) {
          if (p.bucket !== b) continue;
          const angle = fieldAngle(p.x, p.y, elapsed);
          const nx = p.x + Math.cos(angle) * drawStep;
          const ny = p.y + Math.sin(angle) * drawStep;
          ctx.moveTo(p.x, p.y);
          ctx.lineTo(nx, ny);
          p.x = nx;
          p.y = ny;
          p.life -= 1;
          if (p.life <= 0 || nx < -4 || ny < -4 || nx > width + 4 || ny > height + 4) {
            Object.assign(p, spawn(respawnRng), { bucket: p.bucket });
          }
        }
        ctx.strokeStyle = bucket.style;
        ctx.lineWidth = bucket.width;
        ctx.stroke();
      }
    };

    const fade = (alpha: number) => {
      ctx.fillStyle = `rgba(${night[0]},${night[1]},${night[2]},${alpha})`;
      ctx.fillRect(0, 0, width, height);
    };

    const renderSettled = () => {
      // One composed frame: trace each stroke's full streamline once.
      ctx.fillStyle = `rgba(${night[0]},${night[1]},${night[2]},1)`;
      ctx.fillRect(0, 0, width, height);
      const rng = mulberry32(seed ^ 0x9e3779b9);
      for (let n = 0; n < count; n += 1) {
        const p = spawn(rng);
        const bucket = buckets[p.bucket];
        if (!bucket) continue;
        ctx.beginPath();
        ctx.moveTo(p.x, p.y);
        for (let s = 0; s < 12; s += 1) {
          const angle = fieldAngle(p.x, p.y, 0);
          p.x += Math.cos(angle) * 2.2;
          p.y += Math.sin(angle) * 2.2;
          ctx.lineTo(p.x, p.y);
        }
        ctx.strokeStyle = bucket.style;
        ctx.lineWidth = bucket.width;
        ctx.stroke();
      }
    };

    const tick = (now: number) => {
      if (!running) return;
      const dt = Math.min((now - last) / 1000, 0.05);
      last = now;
      elapsed += dt * speed;
      fade(0.05);
      step(1.9 * speed);
      raf = requestAnimationFrame(tick);
    };

    const start = () => {
      if (running || reduced) return;
      running = true;
      last = performance.now();
      raf = requestAnimationFrame(tick);
    };

    const stop = () => {
      running = false;
      cancelAnimationFrame(raf);
    };

    const resize = () => {
      const rect = root.getBoundingClientRect();
      if (rect.width === 0 || rect.height === 0) return;
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.max(1, Math.round(rect.width * dpr));
      canvas.height = Math.max(1, Math.round(rect.height * dpr));
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      width = rect.width;
      height = rect.height;
      ctx.lineCap = "round";
      if (reduced) {
        renderSettled();
      } else {
        seedParticles();
        fade(1);
      }
    };

    const onVisibility = () => {
      if (document.hidden) stop();
      else start();
    };

    resize();
    const observer = new ResizeObserver(resize);
    observer.observe(root);
    document.addEventListener("visibilitychange", onVisibility);
    start();

    return () => {
      stop();
      observer.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, [seed, strokes, speed, reduced]);

  return (
    <div
      ref={rootRef}
      className={cn(
        "relative isolate min-h-[280px] overflow-hidden rounded-2xl border border-[var(--pax-border,rgba(18,17,15,0.12))]",
        "bg-[var(--pax-night,#0b0d10)] text-[var(--pax-paper,#f6f2ea)]",
        className,
      )}
      data-pax-asset="component-shaders-flow-field"
    >
      <canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 h-full w-full" />
      <span className="sr-only">
        Thousands of fine strokes tracing flowing currents in the Pax acid color on a night surface.
      </span>
      <div className="relative z-[1]">{children}</div>
    </div>
  );
}