Group membership drawn as an implicit surface: each item is a charge in an inverse-square field, and a marching-squares isoline at F = 1 is stroked as a bare outline, so items in the same group share one necked contour and an item that leaves visibly pinches off into its own closed loop.
npx shadcn add https://design.helpmarq.com /r/status-metaball-merge.jsonregistry/core/status-metaball-merge/component.tsx"use client";
import { useEffect, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// MetaballMerge — group membership drawn as an IMPLICIT SURFACE. Every item is
// a charge in an inverse-square scalar field, F(x,y) = Σ rᵢ² / (dᵢ² + 1e-3),
// and the only thing ever painted is the marching-squares isoline at F = 1,
// stroked as a bare hairline. Nothing is filled, so the frame stays almost
// entirely empty and the SHAPE carries the whole message: charges close enough
// together share one necked contour, and an item that leaves visibly pinches
// off into its own closed loop. No legend, no caption, no colour code.
// ---------------------------------------------------------------------------
export interface MetaballItem {
id: string;
label: string;
/** starts inside the group (default true) */
member?: boolean;
}
export interface MetaballMergeProps {
items?: MetaballItem[];
/** names the group for assistive tech */
label?: string;
onChange?: (memberIds: string[]) => void;
className?: string;
}
const DEFAULT_ITEMS: MetaballItem[] = [
{ id: "mara", label: "Mara Chen" },
{ id: "jonas", label: "Jonas Weber" },
{ id: "aiko", label: "Aiko Tanaka" },
{ id: "ravi", label: "Ravi Patel", member: false },
];
// --- field / isoline ------------------------------------------------------
const GRID = 4; // px lattice for marching squares
const ISO = 1.0; // threshold — a lone charge's contour is exactly its radius
const EPS = 1e-3; // softening, keeps F finite at a charge centre
const STROKE_W = 1.25;
const STROKE_A = 0.85;
// --- layout ---------------------------------------------------------------
const IN_F = 0.5; // member anchor, as a fraction of the cluster radius Rc
const OUT_F = 1.35; // non-member anchor — comfortably past the merge distance
const FIT = 0.47; // outermost anchor + its radius, as a fraction of min(w,h)
// Charge radius is derived from the member-to-member chord rather than picked
// by eye: two equal charges a chord S apart neck at half-width
// √(2r² − S²/4), so r = 0.4·S puts the waist at ~0.66r — thin enough to read
// as a genuine neck, thick enough that the ±6% ambient breathing never
// severs it. Member slots sit one ARC step apart, so that chord is constant.
const R_OF_CHORD = 0.4;
const HOVER_SWELL = 1.18;
// --- motion ---------------------------------------------------------------
const K = 120; // spring stiffness, s^-2
const ZETA = 1.0; // critically damped
const C = 2 * ZETA * Math.sqrt(K);
const ORBIT_R = 3; // px ambient orbit radius
const ORBIT_HZ = 0.11;
const BREATHE = 0.06; // ±6% radius
const BREATHE_HZ = 0.07;
const DT_MAX = 0.05;
const SETTLED = 0.02;
const TAU = Math.PI * 2;
// 16-case marching-squares segment table; corner bits are TL=1 TR=2 BR=4 BL=8
// and an edge index is 0=top 1=right 2=bottom 3=left. Cases 5 and 10 are the
// saddles and are resolved from the cell-centre sample instead.
const CASES: number[][] = [
[],
[3, 0],
[0, 1],
[3, 1],
[1, 2],
[],
[0, 2],
[3, 2],
[2, 3],
[2, 0],
[],
[2, 1],
[1, 3],
[1, 0],
[0, 3],
[],
];
function hash01(s: string): number {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return ((h >>> 0) % 10000) / 10000;
}
/** shortest signed angle from a to b, in (-π, π] */
function angleDelta(a: number, b: number): number {
let d = (b - a) % TAU;
if (d > Math.PI) d -= TAU;
if (d <= -Math.PI) d += TAU;
return d;
}
type Charge = {
id: string;
home: number; // the item's own direction from the centroid
slot: number; // the direction it is currently anchored along
dist: number; // anchor distance as a fraction of Rc
x: number;
y: number;
vx: number;
vy: number;
r: number;
vr: number;
phase: number;
breathPhase: number;
member: boolean;
hover: boolean;
};
export function MetaballMerge({
items = DEFAULT_ITEMS,
label = "Group",
onChange,
className = "",
}: MetaballMergeProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [members, setMembers] = useState<string[]>(() =>
items.filter((it) => it.member !== false).map((it) => it.id)
);
const membersRef = useRef(members);
membersRef.current = members;
const engineRef = useRef<{
sync: () => void;
setHover: (index: number, on: boolean) => void;
} | null>(null);
useEffect(() => {
engineRef.current?.sync();
}, [members]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
const n = items.length;
// Member slots are one ARC_STEP apart on the inner circle. 2π/(n+2)
// guarantees the arc still has a 3-step gap even with every item inside,
// so the cluster is always an open chain and never closes into a ring
// with a hole punched through the middle.
const ARC_STEP = TAU / (n + 2);
const CHORD = 2 * IN_F * Math.sin(ARC_STEP / 2); // in units of Rc
const R_F = R_OF_CHORD * CHORD; // charge radius, in units of Rc
const charges: Charge[] = items.map((it, i) => {
const home = -Math.PI / 2 + (TAU * i) / Math.max(1, n);
return {
id: it.id,
home,
slot: home,
dist: OUT_F,
x: 0,
y: 0,
vx: 0,
vy: 0,
r: 0,
vr: 0,
phase: hash01(it.id) * TAU,
breathPhase: hash01(it.id + "~b") * TAU,
member: false,
hover: false,
};
});
let ink = "currentColor";
let w = 0;
let h = 0;
let cx = 0;
let cy = 0;
let rc = 0;
let gw = 0;
let gh = 0;
let field = new Float32Array(0);
let sized = false;
let seeded = false;
let raf = 0;
let last = 0;
let t = 0;
const readInk = () => {
ink = getComputedStyle(canvas).color;
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
if (rect.width < 8 || rect.height < 8) {
sized = false;
return;
}
w = rect.width;
h = rect.height;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.round(w * dpr));
canvas.height = Math.max(1, Math.round(h * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
cx = w / 2;
cy = h / 2;
// the outermost anchor plus its own radius has to stay inside the box
rc = (FIT * Math.min(w, h)) / (OUT_F + R_F);
gw = Math.floor(w / GRID) + 1;
gh = Math.floor(h / GRID) + 1;
if (field.length !== gw * gh) field = new Float32Array(gw * gh);
sized = true;
};
// Membership decides BOTH the anchor distance and which slot the item
// takes: members are packed onto consecutive slots of an arc centred on
// their own mean direction, so every member is a neighbour of another
// member and the group is always one connected contour — never two
// "grouped" items sitting too far apart to merge. Non-members keep their
// own direction and are pushed out to 1.35·Rc.
const relayout = () => {
const set = membersRef.current;
for (const c of charges) c.member = set.includes(c.id);
const mem = charges.filter((c) => c.member);
for (const c of charges) {
if (!c.member) {
c.slot = c.home;
c.dist = OUT_F;
}
}
if (mem.length === 0) return;
let sx = 0;
let sy = 0;
for (const c of mem) {
sx += Math.cos(c.home);
sy += Math.sin(c.home);
}
const mean =
sx === 0 && sy === 0 ? charges[0]!.home : Math.atan2(sy, sx);
// keep the members' original circular order so slots never cross
mem.sort((a, b) => angleDelta(mean, a.home) - angleDelta(mean, b.home));
const half = (mem.length - 1) / 2;
mem.forEach((c, k) => {
c.slot = mean + (k - half) * ARC_STEP;
c.dist = IN_F;
});
};
const targetOf = (c: Charge) => ({
x: cx + Math.cos(c.slot) * c.dist * rc,
y: cy + Math.sin(c.slot) * c.dist * rc,
r: R_F * rc * (c.hover ? HOVER_SWELL : 1),
});
const seed = () => {
for (const c of charges) {
const tg = targetOf(c);
c.x = tg.x;
c.y = tg.y;
c.r = tg.r;
c.vx = c.vy = c.vr = 0;
}
seeded = true;
};
// integrate every spring one step; returns true while anything still moves
const step = (dt: number) => {
let moving = false;
for (const c of charges) {
const tg = targetOf(c);
const ax = -K * (c.x - tg.x) - C * c.vx;
const ay = -K * (c.y - tg.y) - C * c.vy;
const ar = -K * (c.r - tg.r) - C * c.vr;
c.vx += ax * dt;
c.vy += ay * dt;
c.vr += ar * dt;
c.x += c.vx * dt;
c.y += c.vy * dt;
c.r += c.vr * dt;
if (
Math.abs(c.x - tg.x) > SETTLED ||
Math.abs(c.y - tg.y) > SETTLED ||
Math.abs(c.r - tg.r) > SETTLED ||
Math.abs(c.vx) > SETTLED ||
Math.abs(c.vy) > SETTLED ||
Math.abs(c.vr) > SETTLED
) {
moving = true;
}
}
return moving;
};
// resolved charge positions/radii for this frame, ambient pulse folded in
const px = new Float64Array(n);
const py = new Float64Array(n);
const pr2 = new Float64Array(n);
const resolve = (time: number) => {
for (let i = 0; i < n; i++) {
const c = charges[i]!;
if (reduced) {
px[i] = c.x;
py[i] = c.y;
pr2[i] = c.r * c.r;
continue;
}
const a = TAU * ORBIT_HZ * time + c.phase;
px[i] = c.x + Math.cos(a) * ORBIT_R;
py[i] = c.y + Math.sin(a) * ORBIT_R;
const br =
c.r * (1 + BREATHE * Math.sin(TAU * BREATHE_HZ * time + c.breathPhase));
pr2[i] = br * br;
}
};
const fieldAt = (x: number, y: number) => {
let f = 0;
for (let i = 0; i < n; i++) {
const dx = x - px[i]!;
const dy = y - py[i]!;
f += pr2[i]! / (dx * dx + dy * dy + EPS);
}
return f;
};
const render = () => {
if (!sized) return;
ctx.clearRect(0, 0, w, h);
for (let gy = 0; gy < gh; gy++) {
const y = gy * GRID;
const row = gy * gw;
for (let gx = 0; gx < gw; gx++) {
field[row + gx] = fieldAt(gx * GRID, y);
}
}
const path = new Path2D();
// linear interpolation along a cell edge — a midpoint placement here is
// what makes a marching-squares contour look visibly stair-stepped
const lerp = (fa: number, fb: number) => {
const d = fb - fa;
if (Math.abs(d) < 1e-9) return 0.5;
const tx = (ISO - fa) / d;
return tx < 0 ? 0 : tx > 1 ? 1 : tx;
};
for (let gy = 0; gy < gh - 1; gy++) {
const r0 = gy * gw;
const r1 = r0 + gw;
const y0 = gy * GRID;
const y1 = y0 + GRID;
for (let gx = 0; gx < gw - 1; gx++) {
const f00 = field[r0 + gx]!;
const f10 = field[r0 + gx + 1]!;
const f11 = field[r1 + gx + 1]!;
const f01 = field[r1 + gx]!;
let mask = 0;
if (f00 >= ISO) mask |= 1;
if (f10 >= ISO) mask |= 2;
if (f11 >= ISO) mask |= 4;
if (f01 >= ISO) mask |= 8;
if (mask === 0 || mask === 15) continue;
const x0 = gx * GRID;
const x1 = x0 + GRID;
let segs = CASES[mask]!;
if (mask === 5 || mask === 10) {
// saddle: the cell-centre sample decides whether the two inside
// corners are joined through the middle or genuinely separate,
// which is what makes a neck pinch instead of resolving as an X
const inside = fieldAt(x0 + GRID / 2, y0 + GRID / 2) >= ISO;
if (mask === 5) segs = inside ? [0, 1, 2, 3] : [3, 0, 1, 2];
else segs = inside ? [3, 0, 1, 2] : [0, 1, 2, 3];
}
for (let s = 0; s < segs.length; s += 2) {
const ea = segs[s]!;
const eb = segs[s + 1]!;
for (let k = 0; k < 2; k++) {
const e = k === 0 ? ea : eb;
let ptx = 0;
let pty = 0;
if (e === 0) {
ptx = x0 + lerp(f00, f10) * GRID;
pty = y0;
} else if (e === 1) {
ptx = x1;
pty = y0 + lerp(f10, f11) * GRID;
} else if (e === 2) {
ptx = x1 - lerp(f11, f01) * GRID;
pty = y1;
} else {
ptx = x0;
pty = y1 - lerp(f01, f00) * GRID;
}
if (k === 0) path.moveTo(ptx, pty);
else path.lineTo(ptx, pty);
}
}
}
}
ctx.strokeStyle = ink;
ctx.lineWidth = STROKE_W;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.globalAlpha = STROKE_A;
ctx.stroke(path);
ctx.globalAlpha = 1;
};
const drawStill = () => {
if (!sized) return;
resolve(0);
render();
};
const loop = (now: number) => {
const dt = last ? Math.min(DT_MAX, (now - last) / 1000) : 1 / 60;
last = now;
t += dt;
const moving = step(dt);
resolve(t);
render();
// the loop only sleeps when the springs have settled AND there is no
// ambient pulse left to render — i.e. never, unless motion is reduced
raf = document.hidden || (reduced && !moving) ? 0 : requestAnimationFrame(loop);
};
const start = () => {
if (reduced || raf) return;
last = 0;
raf = requestAnimationFrame(loop);
};
const sync = () => {
relayout();
if (!sized) return;
if (reduced) {
// no ambient motion, no glide: jump the anchors and repaint one frame
seed();
drawStill();
} else {
start();
}
};
engineRef.current = {
sync,
setHover: (index, on) => {
// reduced motion: the swell is a pointer-driven animation, so it is
// dropped entirely rather than jumped to — the chip's own focus ring
// still carries the cue
if (reduced) return;
const c = charges[index];
if (!c) return;
c.hover = on;
start();
},
};
const ro = new ResizeObserver(() => {
const before = sized;
resize();
if (!sized) return;
if (!before || !seeded) seed();
if (reduced) drawStill();
else start();
});
ro.observe(canvas);
const mo = new MutationObserver(() => {
readInk();
if (reduced) drawStill();
});
mo.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
const onVis = () => {
if (!document.hidden) start();
};
document.addEventListener("visibilitychange", onVis);
readInk();
relayout();
resize();
if (sized) seed();
if (reduced) drawStill();
else start();
return () => {
engineRef.current = null;
cancelAnimationFrame(raf);
ro.disconnect();
mo.disconnect();
document.removeEventListener("visibilitychange", onVis);
};
}, [items]);
const toggle = (id: string) => {
setMembers((prev) => {
const next = prev.includes(id)
? prev.filter((m) => m !== id)
: [...prev, id];
onChange?.(next);
return next;
});
};
const count = members.length;
return (
<div className={`flex w-full flex-col gap-4 ${className}`}>
<div className="relative w-full" style={{ aspectRatio: "6 / 5" }}>
<canvas
ref={canvasRef}
aria-hidden
className="absolute inset-0 block h-full w-full text-foreground"
/>
</div>
<div className="flex flex-col items-center gap-3">
<div
role="group"
aria-label={label}
className="flex flex-wrap items-center justify-center gap-2"
>
{items.map((it, i) => {
const isMember = members.includes(it.id);
return (
<button
key={it.id}
type="button"
aria-pressed={isMember}
onClick={() => toggle(it.id)}
onPointerEnter={() => engineRef.current?.setHover(i, true)}
onPointerLeave={() => engineRef.current?.setHover(i, false)}
onFocus={() => engineRef.current?.setHover(i, true)}
onBlur={() => engineRef.current?.setHover(i, false)}
className={`flex items-center gap-2 rounded-full border px-3 py-1.5 font-mono text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
isMember
? "border-foreground/40 text-foreground"
: "border-border text-muted"
}`}
>
<span
aria-hidden
className={`size-1.5 rounded-full ${
isMember ? "bg-foreground" : "bg-border"
}`}
/>
{it.label}
</button>
);
})}
</div>
<p
aria-hidden
data-merged={count}
className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted"
>
{count} of {items.length} in {label}
</p>
<span aria-live="polite" className="sr-only">
{`${count} ${count === 1 ? "item" : "items"} grouped`}
</span>
</div>
</div>
);
}
Pick status-metaball-merge when the thing being shown is which items BELONG TOGETHER and the join/leave transition itself matters — the contour necks, thins and pinches apart as an item leaves, so the shape alone reads as membership with no legend. avatar-stack-flock is the better pick when the members are identities to be recognised individually rather than a set whose boundary is the point, and status-sphere-dots when the subject is one process's state rather than a relationship between several items.
Build <MetaballMerge items? label? onChange? className?> as a bare-outline metaball grouping widget: a <canvas> field box above a row of chip buttons. FIELD: every item is a charge, and the scalar field is the classic inverse-square metaball sum F(x, y) = Σᵢ rᵢ² / ((x − xᵢ)² + (y − yᵢ)² + 1e-3) — the 1e-3 softening is what keeps F finite at a charge centre. Because the threshold is exactly 1.0, rᵢ is literally the radius a lone charge's contour would have, and two equal charges merge once they are closer than 2√2·r ≈ 2.83·r; membership is therefore not a colour or a badge, it is whether the surface connects. ISOLINE: marching squares on a 4 px lattice over the panel's field box. F is sampled into a reused Float32Array (reallocated only when the grid size changes), then per cell a 4-bit corner mask (TL=1 TR=2 BR=4 BL=8) is built against the threshold and indexed into the standard 16-case segment table, edges numbered 0=top 1=right 2=bottom 3=left. Every segment endpoint is placed by LINEAR interpolation along its edge, t = (1 − Fa) / (Fb − Fa) clamped to [0,1] — never at the edge midpoint, or the contour comes out visibly stair-stepped at this lattice size. The two saddle cases (mask 5 and 10) are disambiguated by re-evaluating F at the cell centre: if the centre is inside, the two inside corners are joined through the cell and the outside corners are isolated instead, so a neck resolves as a genuine pinch rather than an X. All segments accumulate into one Path2D and are stroked ONCE per frame at lineWidth 1.25 in the canvas's own colour, globalAlpha 0.85 — one stroke call, no fill, no shadow, no glow, which is what keeps the frame almost entirely empty. LAYOUT: each item has a chip button below the canvas and an anchor around the cluster centroid; a member's anchor sits at 0.5·Rc (well inside the merge distance) and a non-member's at 1.35·Rc (well outside it — the gap is about 1.5× the merge distance even with the ambient breathing at its peak), with Rc = 0.47·min(w,h) / (1.35 + r/Rc) so the outermost loop plus its own radius always stays in the box. Members are packed onto consecutive slots of an arc one ARC_STEP = 2π/(n+2) apart, centred on the members' own mean direction and kept in their original circular order — that spacing guarantees the group is always ONE connected chain (never two 'grouped' items too far apart to merge) and that the arc always keeps a three-step gap, so it can never close into a ring with a hole punched through the middle. Non-members keep their own direction. The charge radius is derived from that constant member-to-member chord S rather than picked by eye: since the waist half-width between two equal charges is √(2r² − S²/4), r = 0.4·S puts the neck at about 0.66·r — thin enough to read as a deliberate waist, thick enough that the ambient breathing never severs it. AMBIENT PULSE: each charge's centre orbits its anchor on a 3 px circle at 0.11 Hz with an FNV-hash-derived per-item phase, and rᵢ breathes ±6% at 0.07 Hz on a second hash-derived phase, so the neck between merged blobs visibly thins and thickens on a slow beat with jitter and nothing ever holds a dead pose. STATE: toggling a chip retargets that item's anchor and the charge glides there on a critically damped spring (k = 120 s⁻², ζ = 1, so c = 2√k), so the contour necks, thins and pinches into a separate closed loop over roughly 500 ms — at exactly the moment the item stops belonging. HOVER/FOCUS: pointerenter or keyboard focus on a chip raises that item's rᵢ by 18% on the same spring, so its blob swells and its neck to the cluster fattens — the identity cue is 'which lobe grew', which needs no legend; pointerleave/blur springs it straight back. TOKENS: the canvas is the medium, so ink is read from getComputedStyle(canvas).color at mount (the canvas carries text-foreground) and re-derived on a documentElement class MutationObserver, so a theme flip is live and no colour literal is ever baked in; chips use --border/--foreground and --accent appears only on the chip focus ring. PERF/A11Y: dpr clamped to 2, a ResizeObserver with a zero-size guard, one rAF loop that is the sole canvas writer and pauses on document.hidden, and a sleep condition that stops the loop once every spring is under epsilon AND the ambient pulse is disabled. The canvas is aria-hidden; the chips are real <button aria-pressed> controls inside a role=group, each with a visible accent focus ring, and a visually hidden aria-live=polite region announces 'N items grouped' on every toggle, so the canvas is never the only channel. prefers-reduced-motion: no rAF loop at all — no ambient orbit, no breathing and no pointer/focus swell (the chip's own focus ring carries that cue instead), and a toggle jumps the anchors to their new positions and redraws exactly one frame — the merged-versus-split contour still reads perfectly with zero motion. Props: items ({ id, label, member? }[], default a four-person approver set), label (names the group for assistive tech), onChange (member ids), className. Zero dependencies.