Workspace / component / shaders
Flow Field Shader
paxfx add blocks/component-shaders-flow-fieldlocal onlyTier: 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>
);
}