Metaballs
feGaussianBlur(11) → feColorMatrix(α × 20 − 9)
How it works
Real metaballs, and this
Proper metaballs sum a falloff function from every blob at every pixel and draw the contour where that sum crosses a threshold — an implicit surface, usually a shader or a marching-squares pass.
This piece gets the same silhouette for free. Six ordinary DOM circles are blurred as a group, which spreads each one into a soft alpha ramp; where two ramps overlap the alpha adds up. Blur plus addition is a crude field function, and it behaves like one.
The threshold
The blurred alpha is pushed through an feColorMatrix whose alpha row is 20, −9: multiply alpha by 20 and subtract 9. Anything below 0.45 alpha clamps to transparent, anything above clamps to opaque, and the transition happens over a 0.05 window.
That is a step function applied to the summed field — the same threshold test real metaballs use, just done in the filter graph. It is why the blobs snap into one surface with a taut edge instead of fading into each other, and why a thin neck forms and breaks as they separate.
Both operations run on the group, not per element. Filtering each circle on its own would blur them independently and they would never interact.
Movement
Five blobs travel on Lissajous paths — sin on x against cos on y at unrelated frequencies — which never repeat on a short cycle and need no state. The sixth eases toward the pointer at dt × 6 per frame, so it lags slightly and stretches as it goes.
Positions are written straight to style.transform, so React renders the markup once and never re-renders. The transform is deliberately 2D: a filtered group is re-rasterised every frame whatever its children do, so promoting each blob to its own layer would buy nothing, and WebKit draws filters over promoted layers unreliably.
Source
'use client';
import { useEffect, useRef } from 'react';import { useVisibleRaf } from '../useCraft';
const BLOBS = [ { size: 92, color: '#ff6600', fx: 0.21, fy: 0.17, ax: 0.30, ay: 0.26 }, { size: 74, color: '#ff2d78', fx: 0.13, fy: 0.29, ax: 0.34, ay: 0.22 }, { size: 108, color: '#00d4ff', fx: 0.17, fy: 0.11, ax: 0.26, ay: 0.30 }, { size: 62, color: '#c5ff3b', fx: 0.31, fy: 0.23, ax: 0.32, ay: 0.24 }, { size: 80, color: '#ff9500', fx: 0.09, fy: 0.19, ax: 0.28, ay: 0.28 },];
/** * The classic gooey filter: blur the layer, then crush alpha with a colour * matrix so overlapping circles fuse into one surface. Ambient blobs travel on * lissajous paths; the sixth chases your cursor and melts into whatever it meets. */export default function MetaballGoo() { const wrapRef = useRef<HTMLDivElement>(null); const nodes = useRef<(HTMLDivElement | null)[]>([]); const chaser = useRef<HTMLDivElement>(null); const box = useRef({ w: 0, h: 0 }); const target = useRef({ x: -999, y: -999 }); const at = useRef({ x: -999, y: -999 });
useEffect(() => { const el = wrapRef.current; if (!el) return; const measure = () => { const r = el.getBoundingClientRect(); box.current = { w: r.width, h: r.height }; if (at.current.x < 0) { at.current = { x: r.width / 2, y: r.height / 2 }; target.current = { x: r.width / 2, y: r.height / 2 }; } }; measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, []);
useVisibleRaf(wrapRef, (t, dt) => { const { w, h } = box.current; if (!w || !h) return;
BLOBS.forEach((b, i) => { const node = nodes.current[i]; if (!node) return; const x = w / 2 + Math.sin(t * b.fx * 2 + i) * w * b.ax - b.size / 2; const y = h / 2 + Math.cos(t * b.fy * 2 + i * 1.7) * h * b.ay - b.size / 2; node.style.transform = `translate(${x}px, ${y}px)`; });
const k = Math.min(dt * 6, 1); at.current.x += (target.current.x - at.current.x) * k; at.current.y += (target.current.y - at.current.y) * k; if (chaser.current) { chaser.current.style.transform = `translate(${at.current.x - 45}px, ${at.current.y - 45}px)`; } });
const track = (e: React.PointerEvent<HTMLDivElement>) => { const r = e.currentTarget.getBoundingClientRect(); target.current = { x: e.clientX - r.left, y: e.clientY - r.top }; };
/* * Touch never hovers, so the blob has to answer to a press: without this it * ignored a tap and only woke once a finger had already moved. */ const grab = (e: React.PointerEvent<HTMLDivElement>) => { track(e); try { e.currentTarget.setPointerCapture(e.pointerId); } catch { // Pointer already gone; nothing to capture. } };
// A mouse leaving the stage sends the blob home. A lifted finger hasn't left, // it has finished — so the blob stays where it was put. const release = (e: React.PointerEvent<HTMLDivElement>) => { if (e.pointerType !== 'mouse') return; target.current = { x: box.current.w / 2, y: box.current.h / 2 }; };
return ( <div ref={wrapRef} onPointerDown={grab} onPointerMove={track} onPointerLeave={release} style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden', cursor: 'crosshair' }} > {/* Kept at 1px rather than 0, and never display:none: WebKit has been known to skip filter definitions in an SVG it decides has nothing to draw, and then `filter: url()` silently resolves to nothing. */} <svg aria-hidden style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', pointerEvents: 'none' }} > <defs> <filter id="craft-goo"> <feGaussianBlur in="SourceGraphic" stdDeviation="11" result="blur" /> <feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -9" /> </filter> </defs> </svg>
{/* Nothing inside here asks to be promoted to its own layer: the filter re-rasterises the whole group every frame regardless, so a 3D transform or a will-change would buy no speed, and WebKit renders filters over promoted children unreliably. */} <div style={{ position: 'absolute', inset: 0, filter: 'url(#craft-goo)' }}> {BLOBS.map((b, i) => ( <div key={b.color} ref={el => { nodes.current[i] = el; }} style={{ position: 'absolute', top: 0, left: 0, width: b.size, height: b.size, borderRadius: '50%', background: b.color, }} /> ))} <div ref={chaser} style={{ position: 'absolute', top: 0, left: 0, width: 90, height: 90, borderRadius: '50%', background: '#ffd94d', }} /> </div> </div> );}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 );}