Flow field
θ = noise2(x·0.0045 + t·0.06, y·0.0045 − t·0.04) × 4π
How it works
Heading from noise
Each particle samples a 2D value-noise field at its own position and multiplies the result by 4π to get a heading. Neighbouring particles sample nearby points, get nearly the same angle, and so travel together — that coherence is the entire look. Multiplying by 4π rather than 2π means the field wraps through two full turns across its range, which packs in more curl.
The noise itself is a hash of the integer lattice with smoothstep interpolation between the four surrounding corners. No permutation tables, no gradients: it is value noise, not Perlin, which is blurrier and completely fine for steering.
Feeding time into the sample coordinates on both axes — forward on x, backward on y — makes the field drift diagonally, so the structure never settles.
Cursor swirl
Within about 160px of the pointer, atan2(dy, dx) is added to the heading, weighted by a falloff of 1 − d² / 26000. Adding an angle rather than a force means the particles rotate around the cursor instead of being sucked into it, so the field bends and nothing collapses to a point.
Trails without a background
Fading trails normally mean painting a translucent rectangle of the background colour each frame, which requires knowing that colour. Here the fade uses destination-out compositing instead, which subtracts 5.5% alpha from what is already on the canvas.
The canvas therefore stays transparent, the piece sits over light or dark themes unchanged, and it needs no theme information at all. Stroke weight and alpha are the only things that respond to the theme, because saturated colours read weaker against white.
Source
'use client';
import { useRef } from 'react';import { useCanvasCraft, useCraftTheme, noise2 } from '../useCraft';
const COUNT = 420;const PALETTE = ['#ff6600', '#ff2d78', '#00d4ff', '#c5ff3b'];
interface P { x: number; y: number; px: number; py: number; life: number; hue: number }
/** * Particles steered by a slowly-drifting value-noise field. Trails are erased * with destination-out rather than painted over, so the piece sits on any * background without knowing what colour it is. */export default function FlowField() { const theme = useCraftTheme(); const parts = useRef<P[]>([]); const size = useRef({ w: 0, h: 0 });
const seed = (w: number, h: number) => { size.current = { w, h }; parts.current = Array.from({ length: COUNT }, () => { const x = Math.random() * w; const y = Math.random() * h; return { x, y, px: x, py: y, life: Math.random() * 3, hue: (Math.random() * PALETTE.length) | 0 }; }); };
const canvasRef = useCanvasCraft(({ ctx, w, h, t, dt, pointer }) => { if (!parts.current.length) return;
// Fade previous frames by erasing, not repainting. ctx.globalCompositeOperation = 'destination-out'; ctx.fillStyle = 'rgba(0,0,0,0.055)'; ctx.fillRect(0, 0, w, h); ctx.globalCompositeOperation = 'source-over';
// Saturated strokes read weaker on white, so weight them up in light mode. const light = theme === 'light'; ctx.lineCap = 'round'; ctx.lineWidth = light ? 1.5 : 1.1; const alpha = light ? 0.8 : 0.55;
for (const p of parts.current) { p.px = p.x; p.py = p.y;
// Field angle from noise, plus a swirl around the cursor. const n = noise2(p.x * 0.0045 + t * 0.06, p.y * 0.0045 - t * 0.04); let a = n * Math.PI * 4;
if (pointer.inside) { const dx = p.x - pointer.x; const dy = p.y - pointer.y; const d2 = dx * dx + dy * dy; if (d2 < 26000) { const pull = (1 - d2 / 26000) * 2.4; a += Math.atan2(dy, dx) * pull; } }
const speed = 46; p.x += Math.cos(a) * speed * dt; p.y += Math.sin(a) * speed * dt; p.life -= dt;
// Respawn on death or when it wanders off. if (p.life <= 0 || p.x < -4 || p.x > w + 4 || p.y < -4 || p.y > h + 4) { p.x = Math.random() * w; p.y = Math.random() * h; p.px = p.x; p.py = p.y; p.life = 2 + Math.random() * 3; continue; }
ctx.strokeStyle = PALETTE[p.hue]; ctx.globalAlpha = Math.min(p.life, 1) * alpha; ctx.beginPath(); ctx.moveTo(p.px, p.py); ctx.lineTo(p.x, p.y); ctx.stroke(); }
ctx.globalAlpha = 1; }, seed);
return <canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block' }} />;}The shared loop every craft runs on, plus the noise and dither helpers.
'use client';
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react';
/** Decorative previews don't need every frame; the detail pages get the lot. */const PREVIEW_FPS = 30;
/** * Counts capped loops so each can be given a different phase. Without this they * all skip the same frames and compute on the same ones, so the index page does * nothing for one frame and every simulation at once on the next — which shows * up as a dropped frame rather than as evenly spread work. */let phaseCounter = 0;const PHASES = 3;
/** * rAF loop that only runs while the target is on screen and the tab is visible. * Each craft runs its own, so several visible at once is several animations' * worth of work — which is why the grid previews are capped: on the index a * handful of simulations compete for one main thread, and at a glance nobody can * tell 30 frames from 60. */export function useVisibleRaf( targetRef: RefObject<HTMLElement | null>, onFrame: (t: number, dt: number) => void,) { const cb = useRef(onFrame); cb.current = onFrame;
useEffect(() => { const el = targetRef.current; if (!el) return;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // The class that marks a stage as a thumbnail rather than the main event. const isPreview = el.closest('.craft-stage-preview') !== null; // A frame of slack, so a 30fps cap doesn't skip to 20 by missing a vsync. const minGap = isPreview ? 1000 / PREVIEW_FPS - 4 : 0; const phase = minGap ? ((phaseCounter++ % PHASES) * minGap) / PHASES : 0;
let raf = 0; let running = false; let visible = false; const start = performance.now(); let last = start - phase;
const loop = (now: number) => { raf = requestAnimationFrame(loop); if (now - last < minGap) return; const dt = Math.min((now - last) / 1000, 0.05); last = now; cb.current((now - start) / 1000, dt); };
const play = () => { if (running || !visible || document.hidden) return; running = true; // Keep this loop's phase on resume, or scrolling would resync them all. last = performance.now() - phase; raf = requestAnimationFrame(loop); };
const pause = () => { running = false; cancelAnimationFrame(raf); };
const io = new IntersectionObserver( ([entry]) => { visible = entry.isIntersecting; if (visible) play(); else pause(); }, { threshold: 0.05 }, ); io.observe(el);
const onVis = () => (document.hidden ? pause() : play()); document.addEventListener('visibilitychange', onVis);
// Reduced motion: paint one frame, then stop. if (reduced) { cb.current(0, 0); io.disconnect(); document.removeEventListener('visibilitychange', onVis); return; }
return () => { pause(); io.disconnect(); document.removeEventListener('visibilitychange', onVis); }; }, [targetRef]);}
export interface CraftFrame { ctx: CanvasRenderingContext2D; w: number; h: number; t: number; dt: number; pointer: { x: number; y: number; inside: boolean };}
/** * Canvas sized to its CSS box with DPR scaling, driven by useVisibleRaf. * `onInit` fires once per resize — use it to seed grids and particles. */export function useCanvasCraft( onFrame: (f: CraftFrame) => void, onInit?: (w: number, h: number) => void,) { const canvasRef = useRef<HTMLCanvasElement>(null); const box = useRef({ w: 0, h: 0 }); const pointer = useRef({ x: -9999, y: -9999, inside: false }); const frame = useRef(onFrame); const init = useRef(onInit); frame.current = onFrame; init.current = onInit;
useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return;
const resize = () => { const rect = canvas.getBoundingClientRect(); if (!rect.width || !rect.height) return; const dpr = Math.min(window.devicePixelRatio || 1, 2); box.current = { w: rect.width, h: rect.height }; canvas.width = Math.round(rect.width * dpr); canvas.height = Math.round(rect.height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); init.current?.(rect.width, rect.height); };
resize(); const ro = new ResizeObserver(resize); ro.observe(canvas);
/* * Touch has no hover: pointermove only arrives while a finger is down, and * the browser stops sending it the moment it decides the gesture is a * scroll. So the position is also taken on pointerdown — otherwise a tap, * or a grab that hasn't moved yet, reads as no pointer at all. */ const at = (e: PointerEvent) => { const r = canvas.getBoundingClientRect(); pointer.current = { x: e.clientX - r.left, y: e.clientY - r.top, inside: true }; };
const down = (e: PointerEvent) => { at(e); // Keeps a drag that wanders off the canvas mid-gesture still tracking. try { canvas.setPointerCapture(e.pointerId); } catch { // Pointer already gone; nothing to capture. } };
const up = (e: PointerEvent) => { // A lifted finger stops existing, where a clicked mouse is still hovering. if (e.pointerType !== 'mouse') pointer.current.inside = false; };
const leave = () => { pointer.current.inside = false; };
canvas.addEventListener('pointerdown', down); canvas.addEventListener('pointermove', at); canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', leave); canvas.addEventListener('pointerleave', leave);
return () => { ro.disconnect(); canvas.removeEventListener('pointerdown', down); canvas.removeEventListener('pointermove', at); canvas.removeEventListener('pointerup', up); canvas.removeEventListener('pointercancel', leave); canvas.removeEventListener('pointerleave', leave); }; }, []);
useVisibleRaf(canvasRef, (t, dt) => { const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!ctx) return; frame.current({ ctx, w: box.current.w, h: box.current.h, t, dt, pointer: pointer.current }); });
return canvasRef;}
/** Current theme, kept in sync with the dock's toggle. Starts 'light' so SSR matches. */export function useCraftTheme(): 'light' | 'dark' { const [theme, setTheme] = useState<'light' | 'dark'>('light');
useLayoutEffect(() => { const el = document.documentElement; const read = () => setTheme((el.dataset.theme as 'light' | 'dark') ?? 'light'); read(); const mo = new MutationObserver(read); mo.observe(el, { attributes: true, attributeFilter: ['data-theme'] }); return () => mo.disconnect(); }, []);
return theme;}
/** Seeded PRNG, so anything generated looks the same on every visit. */export function mulberry32(seed: number) { return () => { 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; };}
/** Ordered 4×4 Bayer matrix, normalised to 0..1. Shared by the dithered pieces. */export const BAYER4 = [ 0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5,].map(v => (v + 0.5) / 16);
/** Cheap hash-based value noise — enough character for a flow field, no lookup tables. */export function noise2(x: number, y: number): number { const xi = Math.floor(x); const yi = Math.floor(y); const xf = x - xi; const yf = y - yi; const u = xf * xf * (3 - 2 * xf); const v = yf * yf * (3 - 2 * yf);
const h = (a: number, b: number) => { let n = Math.imul(a, 374761393) + Math.imul(b, 668265263); n = Math.imul(n ^ (n >>> 13), 1274126177); return ((n ^ (n >>> 16)) >>> 0) / 4294967295; };
return ( h(xi, yi) * (1 - u) * (1 - v) + h(xi + 1, yi) * u * (1 - v) + h(xi, yi + 1) * (1 - u) * v + h(xi + 1, yi + 1) * u * v );}