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