{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "morphing-blob",
  "type": "registry:ui",
  "title": "Morphing Blob",
  "description": "An organic SVG blob that morphs continuously, leans toward the pointer and can merge with a gooey twin.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/morphing-blob.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type MorphingBlobFill = \"solid\" | \"linear\" | \"radial\" | \"outline\";\n\nexport interface MorphingBlobProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Number of control points around the outline. More points = more lobes. */\n  complexity?: number;\n  /** Morph speed multiplier. 0 holds the current shape. */\n  speed?: number;\n  /** Radius variation as a fraction of the base radius (0–0.45). */\n  amplitude?: number;\n  /** solid colour · linear gradient · radial gradient · outline only. */\n  fill?: MorphingBlobFill;\n  /** Main colour (any CSS colour). */\n  color?: string;\n  /** Gradient end colour (any CSS colour). */\n  secondaryColor?: string;\n  /** A second, orbiting blob that merges and splits via an SVG goo filter (filled modes). */\n  gooey?: boolean;\n  /** Bulge toward a mouse pointer and squish away from it. */\n  interactive?: boolean;\n}\n\n/* ---------------------------------- Noise ---------------------------------- */\n\n/** Compact seeded 3D simplex noise (after Stefan Gustavson). Returns values in roughly [-1, 1]. */\nfunction createNoise3D(seed = 7) {\n  const p = new Uint8Array(256);\n  for (let i = 0; i < 256; i++) p[i] = i;\n  let s = seed >>> 0 || 1;\n  for (let i = 255; i > 0; i--) {\n    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;\n    const j = s % (i + 1);\n    const t = p[i];\n    p[i] = p[j];\n    p[j] = t;\n  }\n  const perm = new Uint8Array(512);\n  for (let i = 0; i < 512; i++) perm[i] = p[i & 255];\n  const grad = [1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, 1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1, 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1];\n  const F3 = 1 / 3;\n  const G3 = 1 / 6;\n\n  const corner = (hash: number, x: number, y: number, z: number) => {\n    let t = 0.6 - x * x - y * y - z * z;\n    if (t < 0) return 0;\n    const g = (hash % 12) * 3;\n    t *= t;\n    return t * t * (grad[g] * x + grad[g + 1] * y + grad[g + 2] * z);\n  };\n\n  return (x: number, y: number, z: number) => {\n    const sk = (x + y + z) * F3;\n    const i = Math.floor(x + sk);\n    const j = Math.floor(y + sk);\n    const k = Math.floor(z + sk);\n    const t = (i + j + k) * G3;\n    const x0 = x - (i - t);\n    const y0 = y - (j - t);\n    const z0 = z - (k - t);\n    let i1, j1, k1, i2, j2, k2;\n    if (x0 >= y0) {\n      if (y0 >= z0) [i1, j1, k1, i2, j2, k2] = [1, 0, 0, 1, 1, 0];\n      else if (x0 >= z0) [i1, j1, k1, i2, j2, k2] = [1, 0, 0, 1, 0, 1];\n      else [i1, j1, k1, i2, j2, k2] = [0, 0, 1, 1, 0, 1];\n    } else if (y0 < z0) [i1, j1, k1, i2, j2, k2] = [0, 0, 1, 0, 1, 1];\n    else if (x0 < z0) [i1, j1, k1, i2, j2, k2] = [0, 1, 0, 0, 1, 1];\n    else [i1, j1, k1, i2, j2, k2] = [0, 1, 0, 1, 1, 0];\n    const ii = i & 255;\n    const jj = j & 255;\n    const kk = k & 255;\n    const n0 = corner(perm[ii + perm[jj + perm[kk]]], x0, y0, z0);\n    const n1 = corner(perm[ii + i1 + perm[jj + j1 + perm[kk + k1]]], x0 - i1 + G3, y0 - j1 + G3, z0 - k1 + G3);\n    const n2 = corner(perm[ii + i2 + perm[jj + j2 + perm[kk + k2]]], x0 - i2 + 2 * G3, y0 - j2 + 2 * G3, z0 - k2 + 2 * G3);\n    const n3 = corner(perm[ii + 1 + perm[jj + 1 + perm[kk + 1]]], x0 - 0.5, y0 - 0.5, z0 - 0.5);\n    return 32 * (n0 + n1 + n2 + n3);\n  };\n}\n\n// Deterministic (seeded), so the server-rendered shape matches the first client frame.\nconst noise = createNoise3D(3);\n\n/* ---------------------------------- Shape ---------------------------------- */\n\nconst VIEW = 240;\nconst CENTER = VIEW / 2;\nconst BASE_RADIUS = 64;\nconst SATELLITE_RADIUS = 19;\n\ninterface Pull {\n  /** Pointer position in viewBox units. */\n  x: number;\n  y: number;\n  /** 0–1 eased presence of the pointer. */\n  strength: number;\n}\n\nconst round = (n: number) => Math.round(n * 100) / 100;\n\n/** Satellite centre for a given orbit angle: the orbit breathes so it repeatedly merges and splits. */\nfunction satelliteCentre(orbit: number) {\n  const distance = 78 + 18 * Math.sin(orbit * 1.7);\n  return [CENTER + Math.cos(orbit) * distance, CENTER + Math.sin(orbit) * distance] as const;\n}\n\nconst ORBIT_START = -0.25;\n\n/** Closed Catmull-Rom spline through the points, as cubic Bézier segments. */\nfunction smoothPath(xs: number[], ys: number[]) {\n  const n = xs.length;\n  let d = `M${round(xs[0])} ${round(ys[0])}`;\n  for (let i = 0; i < n; i++) {\n    const p0 = (i - 1 + n) % n;\n    const p2 = (i + 1) % n;\n    const p3 = (i + 2) % n;\n    const c1x = xs[i] + (xs[p2] - xs[p0]) / 6;\n    const c1y = ys[i] + (ys[p2] - ys[p0]) / 6;\n    const c2x = xs[p2] - (xs[p3] - xs[i]) / 6;\n    const c2y = ys[p2] - (ys[p3] - ys[i]) / 6;\n    d += `C${round(c1x)} ${round(c1y)} ${round(c2x)} ${round(c2y)} ${round(xs[p2])} ${round(ys[p2])}`;\n  }\n  return `${d}Z`;\n}\n\n/**\n * Noise-driven blob outline. Radii are sampled on a circle in noise space (so the loop is\n * seamless), then bulged toward the pointer and squished on the opposite side.\n */\nfunction blobPath(points: number, amplitude: number, time: number, cx: number, cy: number, radius: number, seed: number, pull?: Pull) {\n  const n = Math.max(3, Math.round(points));\n  const xs: number[] = [];\n  const ys: number[] = [];\n  let pointerAngle = 0;\n  let pointerReach = 0;\n  if (pull && pull.strength > 0.001) {\n    const dx = pull.x - cx;\n    const dy = pull.y - cy;\n    pointerAngle = Math.atan2(dy, dx);\n    // Full effect near the edge, fading out over ~1.6 radii beyond it.\n    const dist = Math.hypot(dx, dy);\n    pointerReach = pull.strength * Math.max(0, Math.min(1, 1 - (dist - radius) / (radius * 1.6)));\n  }\n  for (let i = 0; i < n; i++) {\n    const angle = (i / n) * Math.PI * 2 - Math.PI / 2;\n    const c = Math.cos(angle);\n    const s = Math.sin(angle);\n    let r = radius * (1 + amplitude * noise(c * 0.85 + seed, s * 0.85, time));\n    if (pointerReach > 0) {\n      const facing = Math.cos(angle - pointerAngle);\n      r += radius * pointerReach * (facing > 0 ? 0.24 * facing ** 3 : -0.07 * facing * facing);\n    }\n    xs.push(cx + c * r);\n    ys.push(cy + s * r);\n  }\n  return smoothPath(xs, ys);\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\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* -------------------------------- Component -------------------------------- */\n\n/**\n * An organic SVG blob whose outline morphs continuously. The path is rebuilt each frame from\n * N noise-driven radii joined by a Catmull-Rom spline and written straight to the DOM (no\n * React re-renders). Children are centred on top — ideal behind a call to action.\n */\nexport function MorphingBlob({\n  complexity = 8,\n  speed = 1,\n  amplitude = 0.18,\n  fill = \"linear\",\n  color = \"#FF7448\",\n  secondaryColor = \"#B6F3D1\",\n  gooey = false,\n  interactive = true,\n  className = \"\",\n  style,\n  children,\n  ...props\n}: MorphingBlobProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const blobRef = React.useRef<SVGPathElement>(null);\n  const satelliteRef = React.useRef<SVGPathElement>(null);\n  // The morph clock survives prop changes, so dragging a control does not restart the shape.\n  const clockRef = React.useRef({ time: 0, orbit: ORBIT_START });\n  const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, \"\");\n  const gradientId = `blob-fill-${uid}`;\n  const gooId = `blob-goo-${uid}`;\n\n  const amp = Math.max(0, Math.min(0.45, amplitude));\n  const outline = fill === \"outline\";\n  const satellite = gooey;\n  // Initial (and reduced-motion) shape, computed purely from props.\n  const initialBlob = blobPath(complexity, amp, 0, CENTER, CENTER, BASE_RADIUS, 0);\n  const [sx0, sy0] = satelliteCentre(ORBIT_START);\n  const initialSatellite = blobPath(6, amp * 0.6, 0, sx0, sy0, SATELLITE_RADIUS, 4.2);\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const blob = blobRef.current;\n    if (!root || !blob || reduceMotion) return;\n\n    const clock = clockRef.current;\n    let raf = 0;\n    let last = 0;\n    let inView = false;\n    const pull = { clientX: 0, clientY: 0, x: CENTER, y: CENTER, inside: false, strength: 0 };\n\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = Math.min(64, last ? now - last : 16.7);\n      last = now;\n      const f = dt / 16.7;\n      clock.time += dt * 0.00028 * speed;\n      clock.orbit += dt * 0.00042 * speed;\n\n      if (pull.inside) {\n        // Map client coordinates into the square viewBox (meet = fitted to the smaller side).\n        const rect = root.getBoundingClientRect();\n        const scale = VIEW / Math.max(1, Math.min(rect.width, rect.height));\n        pull.x = CENTER + (pull.clientX - (rect.left + rect.width / 2)) * scale;\n        pull.y = CENTER + (pull.clientY - (rect.top + rect.height / 2)) * scale;\n      }\n      pull.strength += ((pull.inside ? 1 : 0) - pull.strength) * (1 - Math.pow(0.9, f));\n\n      // The whole blob leans a little toward the pointer.\n      const lean = 5 * pull.strength;\n      const lx = pull.strength > 0.001 ? Math.cos(Math.atan2(pull.y - CENTER, pull.x - CENTER)) * lean : 0;\n      const ly = pull.strength > 0.001 ? Math.sin(Math.atan2(pull.y - CENTER, pull.x - CENTER)) * lean : 0;\n      blob.setAttribute(\"d\", blobPath(complexity, amp, clock.time, CENTER + lx, CENTER + ly, BASE_RADIUS, 0, pull));\n\n      const sat = satelliteRef.current;\n      if (sat) {\n        const [sx, sy] = satelliteCentre(clock.orbit);\n        sat.setAttribute(\"d\", blobPath(6, amp * 0.6, clock.time * 1.3, sx, sy, SATELLITE_RADIUS, 4.2));\n      }\n      schedule();\n    };\n\n    const schedule = () => {\n      if (!raf && inView && !document.hidden) raf = requestAnimationFrame(frame);\n    };\n    const stop = () => {\n      cancelAnimationFrame(raf);\n      raf = 0;\n      last = 0;\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\") return;\n      pull.clientX = e.clientX;\n      pull.clientY = e.clientY;\n      pull.inside = true;\n    };\n    const onPointerLeave = () => {\n      pull.inside = false;\n    };\n    const onVisibility = () => (document.hidden ? stop() : schedule());\n\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      if (inView) schedule();\n      else stop();\n    });\n    io.observe(root);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    if (interactive) {\n      root.addEventListener(\"pointermove\", onPointerMove, { passive: true });\n      root.addEventListener(\"pointerleave\", onPointerLeave);\n    }\n\n    return () => {\n      stop();\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      root.removeEventListener(\"pointermove\", onPointerMove);\n      root.removeEventListener(\"pointerleave\", onPointerLeave);\n    };\n  }, [complexity, amp, speed, interactive, reduceMotion, satellite]);\n\n  const paint = outline ? \"none\" : fill === \"solid\" ? color : `url(#${gradientId})`;\n\n  return (\n    <div\n      ref={rootRef}\n      className={`relative flex h-full w-full items-center justify-center ${className}`}\n      style={style}\n      {...props}\n    >\n      <svg\n        aria-hidden=\"true\"\n        focusable=\"false\"\n        viewBox={`0 0 ${VIEW} ${VIEW}`}\n        preserveAspectRatio=\"xMidYMid meet\"\n        className=\"pointer-events-none absolute inset-0 h-full w-full overflow-visible\"\n      >\n        <defs>\n          {fill === \"radial\" ? (\n            <radialGradient id={gradientId} cx=\"42%\" cy=\"38%\" r=\"70%\">\n              <stop offset=\"0%\" style={{ stopColor: secondaryColor }} />\n              <stop offset=\"100%\" style={{ stopColor: color }} />\n            </radialGradient>\n          ) : (\n            <linearGradient id={gradientId} x1=\"0%\" y1=\"0%\" x2=\"100%\" y2=\"100%\">\n              <stop offset=\"0%\" style={{ stopColor: color }} />\n              <stop offset=\"100%\" style={{ stopColor: secondaryColor }} />\n            </linearGradient>\n          )}\n          {satellite && !outline ? (\n            <filter id={gooId} x=\"-20%\" y=\"-20%\" width=\"140%\" height=\"140%\" colorInterpolationFilters=\"sRGB\">\n              <feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"6\" result=\"blur\" />\n              {/* Sharpen the blurred alpha back into a crisp, merged edge. */}\n              <feColorMatrix in=\"blur\" mode=\"matrix\" values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 24 -10\" />\n            </filter>\n          ) : null}\n        </defs>\n        <g\n          filter={satellite && !outline ? `url(#${gooId})` : undefined}\n          style={{ fill: paint, stroke: outline ? color : \"none\" }}\n          strokeWidth={outline ? 1.5 : undefined}\n          vectorEffect={outline ? \"non-scaling-stroke\" : undefined}\n        >\n          <path ref={blobRef} d={initialBlob} vectorEffect={outline ? \"non-scaling-stroke\" : undefined} />\n          {satellite ? <path ref={satelliteRef} d={initialSatellite} vectorEffect={outline ? \"non-scaling-stroke\" : undefined} /> : null}\n        </g>\n      </svg>\n      {children != null && <div className=\"relative z-10\">{children}</div>}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "3d"
  ],
  "docs": "Documentation: https://wancraft.my/components/morphing-blob"
}
