Skip to main content

ns-ui

Status Metaball Merge

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.

Usage

registry/core/status-metaball-merge/demo.tsx
"use client";

import { MetaballMerge, type MetaballItem } from "./component";

// a release-approval group: three engineers currently hold the approval, the
// on-call contractor does not. Module-scope so the array identity is stable.
const APPROVERS: MetaballItem[] = [
  { id: "mara", label: "Mara Chen" },
  { id: "jonas", label: "Jonas Weber" },
  { id: "aiko", label: "Aiko Tanaka" },
  { id: "ravi", label: "Ravi Patel", member: false },
];

export default function MetaballMergeDemo() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-14">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / status-metaball-merge
      </p>

      <div className="w-full max-w-[480px] rounded-xl border border-border bg-surface px-6 pb-6 pt-3">
        <MetaballMerge items={APPROVERS} label="Release approvers" />
      </div>

      <p className="max-w-md text-center font-mono text-[10px] leading-relaxed text-ns-muted">
        one contour per group — merged members share a necked outline, whoever
        leaves pinches off into a loop of their own
      </p>
    </div>
  );
}

Component

registry/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 {
  /** the mergeable blobs */
  items?: MetaballItem[];
  /** names the group for assistive tech */
  label?: string;
  /** called with the full list of selected (merged) ids after any toggle */
  onChange?: (memberIds: string[]) => void;
  /** extra classes merged onto the rendered root element */
  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-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
                  isMember
                    ? "border-foreground/40 text-foreground"
                    : "border-border text-ns-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-ns-muted"
        >
          {count} of {items.length} in {label}
        </p>
        <span aria-live="polite" className="sr-only">
          {`${count} ${count === 1 ? "item" : "items"} grouped`}
        </span>
      </div>
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/status-metaball-merge.tsx, so point the import there.

Use when 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.

Install

npx shadcn add https://design.helpmarq.com/r/status-metaball-merge.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

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 --ns-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.

Props

PropTypeDefaultDescription
items?MetaballItem[]DEFAULT_ITEMSthe mergeable blobs
label?string"Group"names the group for assistive tech
onChange?(memberIds: string[]) => void—called with the full list of selected (merged) ids after any toggle
className?string—extra classes merged onto the rendered root element