"use client";
import * as React from "react";
export type FlowFieldPointer = "vortex" | "repel" | "none";
export interface FlowFieldProps extends React.ComponentPropsWithoutRef<"div"> {
/** Number of particles before adaptive reduction. */
particleCount?: number;
/** Drift speed multiplier. 0 freezes the particles (the field still renders). */
speed?: number;
/** Field frequency: higher values give tighter, busier swirls. */
noiseScale?: number;
/** Trail length, in recorded positions per particle. */
trail?: number;
/** Trail stroke width, in px. */
lineWidth?: number;
/** Main particle colour (any CSS colour). */
color?: string;
/** Second palette colour, used by one particle in three (any CSS colour). */
secondaryColor?: string;
/** How particles react to a mouse pointer. */
pointer?: FlowFieldPointer;
/** Scale the particle count with the container area and shed particles when frames drop. */
adaptive?: boolean;
}
type RGB = [number, number, number];
/* ---------------------------------- Colour --------------------------------- */
let probe: CanvasRenderingContext2D | null = null;
/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */
function resolveColor(input: string, fallback: RGB): RGB {
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!probe) return fallback;
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = "rgba(0, 0, 0, 0)";
probe.fillStyle = input; // invalid colours are ignored → transparent → fallback
probe.fillRect(0, 0, 1, 1);
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
return a === 0 ? fallback : [r, g, b];
}
const luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
/** On light themes (dark ink), very light colours are pulled toward the ink so they stay visible. */
function legible(color: RGB, ink: RGB): RGB {
if (luminance(ink) > 0.5 || luminance(color) < 0.62) return color;
return color.map((v, i) => Math.round(v + (ink[i] - v) * 0.42)) as RGB;
}
/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */
function observeTheme(el: Element, onChange: () => void) {
const mo = new MutationObserver(onChange);
const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
mo.observe(document.documentElement, options);
const scoped = el.parentElement?.closest("[data-theme]");
if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", onChange);
return () => {
mo.disconnect();
mq.removeEventListener("change", onChange);
};
}
/* ---------------------------------- Noise ---------------------------------- */
/** Compact seeded 3D simplex noise (after Stefan Gustavson). Returns values in roughly [-1, 1]. */
function createNoise3D(seed = 7) {
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
let s = seed >>> 0 || 1;
for (let i = 255; i > 0; i--) {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
const j = s % (i + 1);
const t = p[i];
p[i] = p[j];
p[j] = t;
}
const perm = new Uint8Array(512);
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
const grad = [1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, 1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1, 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1];
const F3 = 1 / 3;
const G3 = 1 / 6;
const corner = (hash: number, x: number, y: number, z: number) => {
let t = 0.6 - x * x - y * y - z * z;
if (t < 0) return 0;
const g = (hash % 12) * 3;
t *= t;
return t * t * (grad[g] * x + grad[g + 1] * y + grad[g + 2] * z);
};
return (x: number, y: number, z: number) => {
const sk = (x + y + z) * F3;
const i = Math.floor(x + sk);
const j = Math.floor(y + sk);
const k = Math.floor(z + sk);
const t = (i + j + k) * G3;
const x0 = x - (i - t);
const y0 = y - (j - t);
const z0 = z - (k - t);
let i1, j1, k1, i2, j2, k2;
if (x0 >= y0) {
if (y0 >= z0) [i1, j1, k1, i2, j2, k2] = [1, 0, 0, 1, 1, 0];
else if (x0 >= z0) [i1, j1, k1, i2, j2, k2] = [1, 0, 0, 1, 0, 1];
else [i1, j1, k1, i2, j2, k2] = [0, 0, 1, 1, 0, 1];
} else if (y0 < z0) [i1, j1, k1, i2, j2, k2] = [0, 0, 1, 0, 1, 1];
else if (x0 < z0) [i1, j1, k1, i2, j2, k2] = [0, 1, 0, 0, 1, 1];
else [i1, j1, k1, i2, j2, k2] = [0, 1, 0, 1, 1, 0];
const ii = i & 255;
const jj = j & 255;
const kk = k & 255;
const n0 = corner(perm[ii + perm[jj + perm[kk]]], x0, y0, z0);
const n1 = corner(perm[ii + i1 + perm[jj + j1 + perm[kk + k1]]], x0 - i1 + G3, y0 - j1 + G3, z0 - k1 + G3);
const n2 = corner(perm[ii + i2 + perm[jj + j2 + perm[kk + k2]]], x0 - i2 + 2 * G3, y0 - j2 + 2 * G3, z0 - k2 + 2 * G3);
const n3 = corner(perm[ii + 1 + perm[jj + 1 + perm[kk + 1]]], x0 - 0.5, y0 - 0.5, z0 - 0.5);
return 32 * (n0 + n1 + n2 + n3);
};
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* -------------------------------- Component -------------------------------- */
const TAU = Math.PI * 2;
const REFERENCE_AREA = 1280 * 720;
const POINTER_RADIUS = 180;
const ALPHA_BUCKETS = 8;
const STEP_MS = 1000 / 60;
/**
* Particles drifting along a slowly evolving simplex-noise vector field, drawn as fading
* polyline trails on one canvas. Trails are rebuilt from a per-particle history every frame,
* so the canvas stays transparent (no smearing, no ghost pixels) and follows theme changes.
*/
export function FlowField({
particleCount = 1200,
speed = 1,
noiseScale = 3,
trail = 20,
lineWidth = 1,
color = "#FF7448",
secondaryColor = "#B6F3D1",
pointer = "vortex",
adaptive = true,
className = "",
children,
...props
}: FlowFieldProps) {
const reduceMotion = usePrefersReducedMotion();
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
const noise = createNoise3D(11);
const length = Math.max(2, Math.round(trail));
const frequency = Math.max(0.1, noiseScale) / 1000;
let width = 0;
let height = 0;
let dpr = 1;
let fieldTime = 0;
let head = 0;
let raf = 0;
let last = 0;
let inView = false;
let sampleTime = 0;
let sampleFrames = 0;
let pending = 0; // simulated time not yet stepped, in ms
let palette: [string[], string[]] = [[], []];
// Particle state in flat typed arrays. `hist` is a ring buffer of `length` positions per particle.
let count = 0;
let target = 0;
let px = new Float32Array(0);
let py = new Float32Array(0);
let life = new Float32Array(0);
let age = new Uint8Array(0);
let hist = new Float32Array(0);
const ptr = { clientX: 0, clientY: 0, x: 0, y: 0, inside: false, strength: 0 };
const spawn = (i: number) => {
px[i] = Math.random() * width;
py[i] = Math.random() * height;
life[i] = 90 + Math.random() * 320;
age[i] = 0;
};
const allocate = (next: number) => {
count = next;
px = new Float32Array(count);
py = new Float32Array(count);
life = new Float32Array(count);
age = new Uint8Array(count);
hist = new Float32Array(count * length * 2);
for (let i = 0; i < count; i++) {
spawn(i);
// Stagger lifetimes so particles do not all respawn together.
life[i] *= Math.random();
}
};
const readTheme = () => {
const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);
const colors = [legible(resolveColor(color, [255, 116, 72]), ink), legible(resolveColor(secondaryColor, [182, 243, 209]), ink)];
palette = colors.map((c) =>
Array.from({ length: ALPHA_BUCKETS }, (_, b) => {
// Bucket 0 holds the newest segments; alpha falls off toward the tail.
const a = 0.85 * Math.pow(1 - b / ALPHA_BUCKETS, 1.1);
return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${a.toFixed(3)})`;
}),
) as [string[], string[]];
};
/** Advances every particle by one step (`f` = frames at 60fps). */
const step = (f: number) => {
head = (head + 1) % length;
const move = 1.6 * speed * f;
const reach = POINTER_RADIUS;
const interact = pointer !== "none" && ptr.strength > 0.01;
for (let i = 0; i < count; i++) {
let x = px[i];
let y = py[i];
const angle = noise(x * frequency, y * frequency, fieldTime) * TAU;
let vx = Math.cos(angle);
let vy = Math.sin(angle);
if (interact) {
const dx = x - ptr.x;
const dy = y - ptr.y;
const d = Math.sqrt(dx * dx + dy * dy) || 1;
if (d < reach) {
const fall = (1 - d / reach) * (1 - d / reach) * ptr.strength;
if (pointer === "vortex") {
// Mostly tangential flow with a slight pull inward: a gentle whirlpool.
vx += (-dy / d) * fall * 2.4 - (dx / d) * fall * 0.35;
vy += (dx / d) * fall * 2.4 - (dy / d) * fall * 0.35;
} else {
vx += (dx / d) * fall * 2.8;
vy += (dy / d) * fall * 2.8;
}
}
}
x += vx * move;
y += vy * move;
life[i] -= f;
if (life[i] <= 0 || x < -2 || y < -2 || x > width + 2 || y > height + 2) {
spawn(i);
x = px[i];
y = py[i];
}
px[i] = x;
py[i] = y;
const slot = (i * length + head) * 2;
hist[slot] = x;
hist[slot + 1] = y;
if (age[i] < length) age[i]++;
}
};
const draw = () => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
ctx.lineWidth = lineWidth;
ctx.lineCap = "round";
ctx.lineJoin = "round";
const segments = length - 1;
const buckets = Math.min(ALPHA_BUCKETS, segments);
for (let tone = 0; tone < 2; tone++) {
for (let b = 0; b < buckets; b++) {
// Segment range [j0, j1] (0 = newest) covered by this alpha bucket.
const j0 = Math.floor((b * segments) / buckets);
const j1 = Math.floor(((b + 1) * segments) / buckets);
ctx.beginPath();
for (let i = 0; i < count; i++) {
// Every third particle uses the secondary colour.
if ((i % 3 === 0 ? 1 : 0) !== tone) continue;
const valid = age[i] - 1; // number of drawable segments
if (valid <= j0) continue;
const end = Math.min(j1, valid);
const base = i * length;
let slot = (base + ((head - end + length * 2) % length)) * 2;
ctx.moveTo(hist[slot], hist[slot + 1]);
for (let j = end - 1; j >= j0; j--) {
slot = (base + ((head - j + length * 2) % length)) * 2;
ctx.lineTo(hist[slot], hist[slot + 1]);
}
}
ctx.strokeStyle = palette[tone][Math.round((b / buckets) * (ALPHA_BUCKETS - 1))];
ctx.stroke();
}
}
};
/** Reduced motion: pre-run the simulation so the still frame shows full streamlines. */
const drawStill = () => {
for (let n = 0; n < length; n++) step(1);
draw();
};
const frame = (now: number) => {
raf = 0;
const dt = Math.min(64, last ? now - last : 16.7);
last = now;
const f = dt / 16.7;
if (ptr.inside) {
const rect = canvas.getBoundingClientRect();
ptr.x = ptr.clientX - rect.left;
ptr.y = ptr.clientY - rect.top;
}
ptr.strength += ((ptr.inside ? 1 : 0) - ptr.strength) * (1 - Math.pow(0.9, f));
fieldTime += dt * 0.00006 * Math.max(0.15, speed);
// Fixed 60Hz simulation steps, so trail length (in px) is the same on 60, 120 or 144Hz displays.
pending = Math.min(pending + dt, STEP_MS * 4);
let stepped = false;
while (pending >= STEP_MS) {
step(1);
pending -= STEP_MS;
stepped = true;
}
if (stepped) draw();
if (adaptive) {
sampleTime += dt;
sampleFrames++;
if (sampleFrames >= 90) {
// Below ~40fps: shed a quarter of the particles (never below 30% of the target).
if (sampleTime / sampleFrames > 25 && count > target * 0.3) {
count = Math.round(count * 0.75);
}
sampleTime = 0;
sampleFrames = 0;
}
}
schedule();
};
const schedule = () => {
if (!raf && inView && !reduceMotion && !document.hidden) raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
if (Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1 && count > 0) return;
width = rect.width;
height = rect.height;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
const areaScale = adaptive ? Math.min(1, Math.max(0.3, (width * height) / REFERENCE_AREA)) : 1;
target = Math.max(40, Math.round(particleCount * areaScale));
allocate(target);
if (reduceMotion) drawStill();
else draw();
schedule();
};
const onThemeChange = () => {
readTheme();
if (!raf) draw();
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType !== "mouse" && e.pointerType !== "pen") return;
const rect = canvas.getBoundingClientRect();
ptr.clientX = e.clientX;
ptr.clientY = e.clientY;
const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
if (inside && !ptr.inside && ptr.strength < 0.01) {
ptr.x = e.clientX - rect.left;
ptr.y = e.clientY - rect.top;
}
ptr.inside = inside;
};
const onPointerLeave = () => {
ptr.inside = false;
};
const onVisibility = () => (document.hidden ? stop() : schedule());
// Pointer tracking lives on the window (only while visible), so content layered above
// the field never blocks it and the field never blocks the content.
const listenPointer = pointer !== "none" && !reduceMotion;
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
if (inView) {
if (listenPointer) {
window.addEventListener("pointermove", onPointerMove, { passive: true });
document.documentElement.addEventListener("pointerleave", onPointerLeave);
}
schedule();
} else {
window.removeEventListener("pointermove", onPointerMove);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
ptr.inside = false;
stop();
}
});
readTheme();
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
io.observe(canvas);
const unobserveTheme = observeTheme(canvas, onThemeChange);
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
ro.disconnect();
io.disconnect();
unobserveTheme();
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("pointermove", onPointerMove);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
};
}, [particleCount, speed, noiseScale, trail, lineWidth, color, secondaryColor, pointer, adaptive, reduceMotion]);
return (
<div className={`absolute inset-0 overflow-hidden ${className}`} {...props}>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 dark:text-zinc-100"
/>
{children != null && <div className="relative h-full w-full">{children}</div>}
</div>
);
}