"use client";
import * as React from "react";
export type MetaballsMode = "fill" | "outline" | "contours";
export interface MetaballsProps extends React.ComponentPropsWithoutRef<"div"> {
/** Number of drifting blobs (2–16). */
count?: number;
/** Drift speed multiplier; 0 freezes the blobs (the pointer ball still works). */
speed?: number;
/** Blob size multiplier, relative to the container. */
size?: number;
/** Solid lava fill, a single crisp outline, or topographic iso-lines. */
mode?: MetaballsMode;
/** The pointer joins the field as an extra ball. */
pointer?: boolean;
/** Colour at the blob edges, where the field is weakest (any CSS colour). */
accent?: string;
/** Colour at the blob cores, where the field is strongest (any CSS colour). */
secondary?: string;
}
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.45)) 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);
};
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* --------------------------------- Helpers --------------------------------- */
/** Deterministic 0–1 hash, so every blob has a stable personality (and SSR has nothing random). */
const hash = (n: number) => {
const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return s - Math.floor(s);
};
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
/** Field threshold: the blob surface sits where Σ r²/d² = 1. */
const THRESHOLD = 1;
/** Iso-levels drawn in contour mode, as multiples of the threshold. */
const CONTOUR_LEVELS = [1, 1.45, 2.1, 3.1, 4.6, 7];
/** Upper bound on field samples per frame; the grid coarsens on large containers. */
const MAX_CELLS = 64000;
/** Never sample finer than one cell per 2×2 CSS pixels (a quarter of the pixel count). */
const MIN_CELL = 2;
const CORE_LOG = Math.log(7); // the field strength (× threshold) that reaches the core colour
const STILL_TIME = 7.3; // seconds into the drift used for the reduced-motion still frame
/**
* Marching-squares segments per corner mask (tl=8, tr=4, br=2, bl=1). Edges: 0 top, 1 right,
* 2 bottom, 3 left. The saddle cases 5 and 10 are resolved by the cell's centre value at draw time.
*/
const SEGMENTS: number[][] = [
[], [3, 2], [2, 1], [3, 1], [0, 1], [], [0, 2], [3, 0],
[3, 0], [0, 2], [], [0, 1], [3, 1], [2, 1], [3, 2], [],
];
/**
* Lava-lamp metaballs on one canvas. Each frame the implicit field Σ r²/d² is sampled on a coarse
* grid (at most a quarter of the pixel count), then either written to a small image that is
* upscaled with smoothing — the edge alpha is a signed-distance ramp (field − threshold) / |∇field|,
* so the threshold stays smooth after scaling — or traced with marching squares into crisp
* vector outlines. Colour shifts from `accent` at the surface to `secondary` in the cores.
*/
export function Metaballs({
count = 6,
speed = 0.6,
size = 1,
mode = "fill",
pointer = true,
accent = "#FF7448",
secondary = "#FFC46B",
className = "",
children,
...props
}: MetaballsProps) {
const reduceMotion = usePrefersReducedMotion();
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
const low = document.createElement("canvas");
const lowCtx = low.getContext("2d");
if (!canvas || !ctx || !lowCtx) return;
const n = Math.max(2, Math.min(16, Math.round(count)));
const drift = Math.max(0, speed);
const scale = Math.max(0.2, size);
const interactive = pointer && !reduceMotion;
let width = 0;
let height = 0;
let dpr = 1;
let cell = MIN_CELL;
let gw = 0;
let gh = 0;
let field = new Float32Array(0);
let image: ImageData | null = null;
let raf = 0;
let last = 0;
let time = reduceMotion ? STILL_TIME : hash(n) * 20;
let inView = false;
let edgeRGB: RGB = [255, 116, 72];
let coreRGB: RGB = [255, 196, 107];
// Blob personalities: radius factor, Lissajous amplitudes, frequencies and phases.
const blobs = Array.from({ length: n }, (_, i) => ({
r: 0.7 + hash(i + 1) * 0.65,
ax: 0.26 + hash(i + 11) * 0.18,
ay: 0.22 + hash(i + 21) * 0.16,
fx: 0.11 + hash(i + 31) * 0.2,
fy: 0.09 + hash(i + 41) * 0.2,
px: hash(i + 51) * Math.PI * 2,
py: hash(i + 61) * Math.PI * 2,
}));
// Per-frame ball list (blobs + pointer): x, y, r² × strength.
const bx = new Float32Array(n + 1);
const by = new Float32Array(n + 1);
const br = new Float32Array(n + 1);
const ptr = { clientX: 0, clientY: 0, x: 0, y: 0, inside: false, touch: false, strength: 0 };
const readTheme = () => {
const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);
edgeRGB = legible(resolveColor(accent, [255, 116, 72]), ink);
// In fill mode the core sits on the blob, not on the page, so it keeps its exact colour.
const core = resolveColor(secondary, [255, 196, 107]);
coreRGB = mode === "fill" ? core : legible(core, ink);
};
const layout = () => {
cell = Math.max(MIN_CELL, Math.sqrt((width * height) / MAX_CELLS));
gw = Math.max(2, Math.ceil(width / cell));
gh = Math.max(2, Math.ceil(height / cell));
field = new Float32Array(gw * gh);
low.width = gw;
low.height = gh;
image = lowCtx.createImageData(gw, gh);
};
const placeBalls = () => {
const base = scale * Math.sqrt(width * height) * 0.085;
for (let i = 0; i < n; i++) {
const b = blobs[i];
bx[i] = width * (0.5 + b.ax * Math.sin(time * b.fx + b.px));
by[i] = height * (0.5 + b.ay * Math.sin(time * b.fy + b.py));
const r = base * b.r;
br[i] = r * r;
}
const pr = base * 0.95;
bx[n] = ptr.x;
by[n] = ptr.y;
br[n] = pr * pr * ptr.strength;
};
/** Samples Σ r²/d² at every cell centre. */
const sampleField = () => {
const balls = br[n] > 0.5 ? n + 1 : n;
const dy2 = new Float32Array(balls);
for (let j = 0; j < gh; j++) {
const y = (j + 0.5) * cell;
for (let k = 0; k < balls; k++) {
const d = y - by[k];
dy2[k] = d * d;
}
const row = j * gw;
for (let i = 0; i < gw; i++) {
const x = (i + 0.5) * cell;
let sum = 0;
for (let k = 0; k < balls; k++) {
const d = x - bx[k];
sum += br[k] / (d * d + dy2[k] + 1);
}
field[row + i] = sum;
}
}
};
/** Fill mode: per-cell colour + signed-distance alpha into a small image, upscaled with smoothing. */
const drawFill = () => {
if (!image) return;
const data = image.data;
const [er, eg, eb] = edgeRGB;
const [cr, cg, cb] = coreRGB;
for (let j = 0; j < gh; j++) {
for (let i = 0; i < gw; i++) {
const k = j * gw + i;
const f = field[k];
const o = k * 4;
if (f < THRESHOLD * 0.45) {
data[o + 3] = 0;
continue;
}
// Central differences (clamped at the borders) give |∇f| in field units per cell.
const gx = (field[j * gw + Math.min(gw - 1, i + 1)] - field[j * gw + Math.max(0, i - 1)]) * 0.5;
const gy = (field[Math.min(gh - 1, j + 1) * gw + i] - field[Math.max(0, j - 1) * gw + i]) * 0.5;
const grad = Math.sqrt(gx * gx + gy * gy) || 1e-4;
// ≈ signed distance to the surface, in cells. Only meaningful near the surface: close
// to a ball's centre the field is so steep that the estimate would punch holes.
const dist = f > THRESHOLD * 2 ? 1 : (f - THRESHOLD) / grad;
const alpha = clamp01(dist + 0.5);
// Core blend on a log scale, so small blobs warm up too and big ones grade smoothly.
const t = clamp01(Math.log(f / THRESHOLD) / CORE_LOG);
const m = t * t * (3 - 2 * t);
data[o] = er + (cr - er) * m;
data[o + 1] = eg + (cg - eg) * m;
data[o + 2] = eb + (cb - eb) * m;
data[o + 3] = alpha * 255;
}
}
lowCtx.putImageData(image, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(low, 0, 0, gw * cell, gh * cell);
};
/** Marching squares for one iso-level, with linear interpolation along each cell edge. */
const contourPath = (level: number) => {
const path = new Path2D();
const half = cell * 0.5;
const pts = [0, 0, 0, 0, 0, 0, 0, 0]; // x/y for edges 0–3
for (let j = 0; j < gh - 1; j++) {
for (let i = 0; i < gw - 1; i++) {
const a = field[j * gw + i];
const b = field[j * gw + i + 1];
const c = field[(j + 1) * gw + i + 1];
const d = field[(j + 1) * gw + i];
const mask = (a > level ? 8 : 0) | (b > level ? 4 : 0) | (c > level ? 2 : 0) | (d > level ? 1 : 0);
if (mask === 0 || mask === 15) continue;
const x = i * cell + half;
const y = j * cell + half;
pts[0] = x + ((level - a) / (b - a)) * cell;
pts[1] = y;
pts[2] = x + cell;
pts[3] = y + ((level - b) / (c - b)) * cell;
pts[4] = x + ((level - d) / (c - d)) * cell;
pts[5] = y + cell;
pts[6] = x;
pts[7] = y + ((level - a) / (d - a)) * cell;
let segs = SEGMENTS[mask];
if (mask === 5 || mask === 10) {
const centre = (a + b + c + d) / 4 > level;
// Saddle: decide which diagonal pair is connected.
segs = (mask === 5) === centre ? [3, 0, 2, 1] : [0, 1, 3, 2];
}
for (let s = 0; s < segs.length; s += 2) {
path.moveTo(pts[segs[s] * 2], pts[segs[s] * 2 + 1]);
path.lineTo(pts[segs[s + 1] * 2], pts[segs[s + 1] * 2 + 1]);
}
}
}
return path;
};
const mix = (t: number) =>
`rgb(${Math.round(edgeRGB[0] + (coreRGB[0] - edgeRGB[0]) * t)}, ${Math.round(edgeRGB[1] + (coreRGB[1] - edgeRGB[1]) * t)}, ${Math.round(edgeRGB[2] + (coreRGB[2] - edgeRGB[2]) * t)})`;
const draw = () => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
if (!gw) return;
placeBalls();
sampleField();
if (mode === "fill") {
drawFill();
return;
}
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (mode === "outline") {
ctx.lineWidth = 1.5;
ctx.strokeStyle = mix(0);
ctx.stroke(contourPath(THRESHOLD));
return;
}
// Contours: inner levels fade toward the core colour and thin out slightly.
CONTOUR_LEVELS.forEach((level, k) => {
const t = k / (CONTOUR_LEVELS.length - 1);
ctx.lineWidth = 1.25 - t * 0.4;
ctx.globalAlpha = 1 - t * 0.35;
ctx.strokeStyle = mix(t);
ctx.stroke(contourPath(level * THRESHOLD));
});
ctx.globalAlpha = 1;
};
const frame = (now: number) => {
raf = 0;
const dt = Math.min(0.064, last ? (now - last) / 1000 : 1 / 60);
last = now;
time += dt * drift;
if (ptr.inside) {
const rect = canvas.getBoundingClientRect();
const tx = ptr.clientX - rect.left;
const ty = ptr.clientY - rect.top;
// Ease the pointer ball, so it flows like the others instead of snapping.
const ease = 1 - Math.pow(0.001, dt);
ptr.x += (tx - ptr.x) * ease;
ptr.y += (ty - ptr.y) * ease;
}
ptr.strength += ((ptr.inside ? 1 : 0) - ptr.strength) * (1 - Math.pow(0.02, dt));
if (!ptr.inside && ptr.strength < 0.005) ptr.strength = 0;
draw();
if (drift > 0 || ptr.inside || ptr.strength > 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 && gw > 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));
layout();
draw();
schedule();
};
const onThemeChange = () => {
readTheme();
if (!raf) draw();
};
const locate = (e: PointerEvent) => {
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.strength < 0.01) {
// Appear where the pointer is rather than gliding in from the last position.
ptr.x = e.clientX - rect.left;
ptr.y = e.clientY - rect.top;
}
return inside;
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType === "touch" && !ptr.touch) return;
ptr.inside = locate(e);
if (ptr.inside) schedule();
};
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
// Touch: the ball follows the finger while it is down.
ptr.touch = true;
ptr.inside = locate(e);
if (ptr.inside) schedule();
};
const onPointerUp = (e: PointerEvent) => {
if (e.pointerType !== "touch") return;
ptr.touch = false;
ptr.inside = false;
schedule();
};
const onPointerLeave = () => {
ptr.inside = false;
schedule();
};
const onVisibility = () => (document.hidden ? stop() : schedule());
const listen = (on: boolean) => {
if (on) {
window.addEventListener("pointermove", onPointerMove, { passive: true });
window.addEventListener("pointerdown", onPointerDown, { passive: true });
window.addEventListener("pointerup", onPointerUp, { passive: true });
window.addEventListener("pointercancel", onPointerUp, { passive: true });
document.documentElement.addEventListener("pointerleave", onPointerLeave);
} else {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
}
};
// 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 io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
if (inView) {
if (interactive) listen(true);
schedule();
} else {
listen(false);
ptr.inside = false;
ptr.strength = 0;
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();
listen(false);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [count, speed, size, mode, pointer, accent, secondary, 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>
);
}