{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"matrix-orb","type":"registry:ui","title":"Matrix Orb","description":"A liquid metaball orb for voice or AI status: it breathes when idle, ripples when listening and churns off droplets when thinking.","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"registry/ui/matrix-orb.tsx","type":"registry:ui","content":"\"use client\";\n\n// Adapted from Calamansi UI by fujiDevv (MIT License).\n// https://github.com/fujiDevv/calamansi-ui\n//\n// The original renders its fuse with the `liquid-gooey` package; this port inlines an\n// equivalent SVG filter (blur, alpha ramp, edge displacement, inner rim) so the file\n// stays dependency-free.\n\nimport { useEffect, useId, useRef, useState, type ComponentProps } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * A liquid orb: one core and a ring of satellites, plain circles fused by a metaball\n * filter into a single soft body. It breathes when idle, ripples when listening, and\n * churns and throws off droplets when thinking. The geometry is a plain rAF loop\n * writing transforms; no canvas, no re-render per frame.\n */\nexport type MatrixOrbState = \"idle\" | \"listening\" | \"thinking\";\nexport type MatrixOrbVariant = \"white\" | \"calamansi\" | \"slate\" | \"citrus\";\n\nexport type MatrixOrbProps = ComponentProps<\"div\"> & {\n  /** What the orb is doing. Each state moves the body differently. Default: \"idle\" */\n  state?: MatrixOrbState;\n  /** Drive the amplitude yourself, 0 to 1 — an audio level, for instance. */\n  level?: number;\n  /** Width and height of the stage, in pixels. Default: 240 */\n  size?: number;\n  /** Body colour, overriding the palette's ink. */\n  color?: string;\n  /**\n   * Satellites around the core, 2 to 10. More of them means a busier body and\n   * shorter necks between the lobes. Default: 5\n   */\n  lobes?: number;\n  /** Caption under the orb, per state. */\n  labels?: Partial<Record<MatrixOrbState, string>>;\n  /**\n   * Show that caption. Default: true — turn it off when the orb is a small\n   * decorative indicator rather than a status the reader is reading.\n   */\n  caption?: boolean;\n  /** Ink the body is painted in. Default: \"calamansi\" */\n  variant?: MatrixOrbVariant;\n  /**\n   * Run the fuse at all. Off, the blur goes with it and the circles stay crisp and\n   * simply overlap. Default: true\n   */\n  gooey?: boolean;\n  /**\n   * The blur behind the fuse, in pixels — how far a lobe reaches for its neighbour\n   * before the neck between them severs. Defaults to 0.078 of the stage, so the body\n   * reads the same at every size.\n   */\n  viscosity?: number;\n  /**\n   * The alpha ramp's slope: how hard the fused edge is, and so how much a lobe\n   * stretches before it parts. Default: 19\n   */\n  threshold?: number;\n  /**\n   * Max px the body's boundary undulates — the fluid edge. Defaults to 0.012 of the\n   * stage, so the wobble keeps its proportion; 0 gives back the calm, geometric edge.\n   */\n  waviness?: number;\n  /** Noise frequency of the undulation. Lower = longer, lazier waves. */\n  wavinessFreq?: number;\n  /**\n   * CSS `filter` for the body's cast shadow, applied to the *merged* silhouette so it\n   * follows the goo. A glass hairline rim is traced inside the edge too; pass \"none\"\n   * for a bare orb with neither.\n   */\n  shadow?: string;\n};\n\nconst TAU = Math.PI * 2;\nconst STATES: MatrixOrbState[] = [\"idle\", \"listening\", \"thinking\"];\n\nconst LABELS: Record<MatrixOrbState, string> = {\n  idle: \"Idle\",\n  listening: \"Listening\",\n  thinking: \"Thinking\",\n};\n\n/**\n * How big the body stands in each state, sprung rather than switched, so a change\n * of state lands rather than snaps.\n */\nconst SCALE: Record<MatrixOrbState, number> = {\n  idle: 0.88,\n  listening: 1,\n  thinking: 0.92,\n};\n\nconst STIFFNESS = 180;\nconst DAMPING = 26;\n/** An amplitude on its way up arrives faster than it leaves. */\nconst ATTACK = 0.22;\nconst RELEASE = 0.08;\n/** How fast a state's geometry fades in, which is what makes an interruption smooth. */\nconst BLEND = 0.16;\n\n/**\n * The body, in shares of the stage: a core, and satellites whose radius and orbit\n * leave the neck between them as thick as a few pixels of blur. Those two numbers\n * are what the states push around — pull the orbit in and the lobes swallow each\n * other into a ball, push it out past the sever and they come off as droplets.\n */\nconst CORE = 0.155;\nconst SATELLITE = 0.1;\nconst ORBIT = 0.26;\n\n/**\n * The blur, as a share of the stage, when the caller does not name one.\n *\n * A neck dies once the gap it spans passes ~1.35σ — the alpha ramp cuts at 0.5, and\n * that is where the blurred alphas between two circles stop reaching it. At the\n * geometry above, a satellite sits 0.005 of the stage off the core, so the lobes are\n * always joined to the body; it is the neighbours that move. At the default five the\n * gap between them is 0.106, so 0.078 puts them just touching — one silhouette with a\n * dip between the lobes rather than a pinwheel of separate drops. Fewer lobes pull\n * further apart (at three the bumps only meet at the core) and more of them close into\n * a ring, which is what `lobes` tunes its name to.\n */\nconst VISCOSITY_RATIO = 0.078;\n\nconst MIN_LOBES = 2;\nconst MAX_LOBES = 10;\nconst DEFAULT_LOBES = 5;\n\nconst ACCENTS: Record<MatrixOrbVariant, string> = {\n  white: \"text-foreground\",\n  calamansi: \"text-[#b4e84c]\",\n  slate: \"text-[#d8dcff]\",\n  citrus: \"text-[#ffd166]\",\n};\n\n/**\n * The undulation, as a share of the stage, when the caller does not name one.\n *\n * A geometric edge is the one thing a metaball body never has: left alone, the fused\n * silhouette is a run of arcs meeting at exact angles. A little displacement noise is\n * all it takes for the boundary to read as liquid — and it travels, because the noise\n * field is fixed in the silhouette's own space while the lobes move through it.\n */\nconst WAVINESS_RATIO = 0.012;\n\n/**\n * How the body sits on its surface: a cast shadow under the merged silhouette (a\n * drop-shadow follows the goo's alpha) plus a hairline rim traced inside its edge by\n * the filter, so the glass still reads on a dark theme.\n */\nconst ORB_SHADOW = \"drop-shadow(0 18px 36px rgba(0, 0, 0, 0.45))\";\n\n// no Math.abs here, its corners read as a snap at every trough\nfunction envelope(t: number) {\n  const slow = 0.5 + 0.5 * Math.sin(t * 0.62 + 0.4);\n  const fast = 0.5 + 0.5 * Math.sin(t * 1.9 + 1.1);\n  return 0.22 + 0.78 * (0.45 + 0.55 * slow) * fast;\n}\n\n/** Where a satellite sits around the core, and how fat it is, in stage shares. */\nfunction satelliteAt(\n  state: MatrixOrbState,\n  index: number,\n  count: number,\n  t: number,\n  amplitude: number,\n) {\n  if (state === \"listening\") {\n    // a ripple leaving the core, travelling around the ring; the level is how far\n    // it pushes, and at the top of the range a lobe comes off the body entirely\n    const wave = 0.5 + 0.5 * Math.sin(t * 3.2 - index * 1.15);\n    const push = amplitude * wave;\n    return {\n      orbit: ORBIT * (0.85 + 0.75 * push),\n      radius: SATELLITE * (0.9 + 0.3 * wave),\n    };\n  }\n\n  if (state === \"thinking\") {\n    // each lobe runs on its own clock, so they lap each other: a neck stretches,\n    // parts, and closes again as the next one comes round\n    const i = index / Math.max(1, count - 1);\n    return {\n      orbit: ORBIT * (0.78 + 0.42 * Math.sin(t * 1.6 + i * 5.2)),\n      radius: SATELLITE * (0.85 + 0.28 * Math.sin(t * 2.1 + i * 3.4)),\n    };\n  }\n\n  return {\n    orbit: ORBIT * (1 + 0.07 * Math.sin(t * 0.85 + index * 1.3)),\n    radius: SATELLITE * (1 + 0.1 * Math.sin(t * 0.62 + index * 1.9)),\n  };\n}\n\nfunction satelliteAngle(\n  state: MatrixOrbState,\n  index: number,\n  count: number,\n  t: number,\n) {\n  const base = (index / count) * TAU;\n\n  if (state === \"listening\") return base + t * 0.35;\n  // different speeds are what makes the lobes lap one another\n  if (state === \"thinking\") {\n    return base + t * (2.2 + 0.35 * Math.sin(index * 2.1));\n  }\n\n  return base + t * 0.22;\n}\n\nfunction coreAt(state: MatrixOrbState, t: number, amplitude: number) {\n  if (state === \"listening\") return CORE * (0.95 + 0.12 * amplitude);\n  if (state === \"thinking\") return CORE * (0.85 + 0.1 * Math.sin(t * 1.4));\n  return CORE * (1 + 0.05 * Math.sin(t * 0.5));\n}\n\ntype Weights = Record<MatrixOrbState, number>;\n\n/** Every state's weight on one state, for the frames drawn before the loop runs. */\nfunction soleWeights(state: MatrixOrbState): Weights {\n  return {\n    idle: state === \"idle\" ? 1 : 0,\n    listening: state === \"listening\" ? 1 : 0,\n    thinking: state === \"thinking\" ? 1 : 0,\n  };\n}\n\ntype Lobe = {\n  /** Centre, in pixels from the stage's top-left corner. */\n  x: number;\n  y: number;\n  /** Radius, in pixels. */\n  r: number;\n};\n\n/**\n * The body at one instant, as circles to draw: the core first, then the ring of\n * satellites. Taking the state weights rather than a state is what lets an\n * interrupted change blend — the same call serves the loop and a still frame.\n */\nfunction bodyAt(\n  weights: Weights,\n  satellites: number,\n  t: number,\n  amplitude: number,\n  scale: number,\n  size: number,\n): Lobe[] {\n  // the level also swells the whole body, so a louder signal is a bigger one\n  const gain = (0.9 + 0.2 * amplitude) * scale;\n  const centre = size / 2;\n  let core = 0;\n  for (const s of STATES) core += weights[s] * coreAt(s, t, amplitude);\n\n  const body: Lobe[] = [{ x: centre, y: centre, r: core * size * gain }];\n\n  for (let i = 0; i < satellites; i++) {\n    let ox = 0;\n    let oy = 0;\n    let radius = 0;\n\n    for (const s of STATES) {\n      const w = weights[s];\n      if (w < 0.001) continue;\n      const { orbit, radius: r } = satelliteAt(s, i, satellites, t, amplitude);\n      const angle = satelliteAngle(s, i, satellites, t);\n      ox += w * Math.cos(angle) * orbit;\n      oy += w * Math.sin(angle) * orbit;\n      radius += w * r;\n    }\n\n    body.push({\n      x: centre + ox * size * gain,\n      y: centre + oy * size * gain,\n      r: radius * size * gain,\n    });\n  }\n\n  return body;\n}\n\nexport function MatrixOrb({\n  state = \"idle\",\n  level,\n  size = 240,\n  color,\n  lobes = DEFAULT_LOBES,\n  labels,\n  caption = true,\n  variant = \"calamansi\",\n  gooey = true,\n  viscosity,\n  threshold = 19,\n  waviness,\n  wavinessFreq = 0.018,\n  shadow = ORB_SHADOW,\n  className,\n  style,\n  ...props\n}: MatrixOrbProps) {\n  const lobeRefs = useRef<(SVGCircleElement | null)[]>([]);\n  const stageRef = useRef<SVGSVGElement>(null);\n  const stateRef = useRef(state);\n  const levelRef = useRef(level);\n  const redrawRef = useRef<(() => void) | null>(null);\n  const filterId = `matrix-orb-${useId().replace(/:/g, \"\")}`;\n\n  const satellites = Math.min(MAX_LOBES, Math.max(MIN_LOBES, Math.round(lobes)));\n  const blur = viscosity ?? size * VISCOSITY_RATIO;\n  const wave = waviness ?? size * WAVINESS_RATIO;\n  const gap = Math.round(size * 0.055);\n  const bare = shadow === \"none\";\n\n  // one frame of body, used as each circle's first box and as the reduced-motion frame\n  const [seed] = useState(() => bodyAt(soleWeights(state), satellites, 0, envelope(0), SCALE[state], size));\n\n  useEffect(() => {\n    stateRef.current = state;\n    levelRef.current = level;\n  }, [state, level]);\n\n  useEffect(() => {\n    const nodes = lobeRefs.current.filter((node): node is SVGCircleElement => Boolean(node));\n    const stage = stageRef.current;\n    if (nodes.length === 0 || !stage) return;\n\n    const weights: Weights = { idle: 0, listening: 0, thinking: 0 };\n    weights[stateRef.current] = 1;\n\n    // a non-finite level would stick in the smoother forever\n    const levelAt = (t: number) => {\n      const value = levelRef.current;\n      return value === undefined || !Number.isFinite(value) ? envelope(t) : Math.min(1, Math.max(0, value));\n    };\n\n    const place = (node: SVGCircleElement, x: number, y: number, radius: number) => {\n      node.setAttribute(\"cx\", x.toFixed(2));\n      node.setAttribute(\"cy\", y.toFixed(2));\n      node.setAttribute(\"r\", Math.max(0, radius).toFixed(2));\n    };\n\n    const draw = (t: number, amplitude: number, scale: number) => {\n      const body = bodyAt(weights, satellites, t, amplitude, scale, size);\n      for (let i = 0; i < body.length && i < nodes.length; i++) {\n        const { x, y, r } = body[i];\n        place(nodes[i], x, y, r);\n      }\n    };\n\n    if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) {\n      // one frozen frame of the current state, and a fresh one whenever it changes\n      redrawRef.current = () => {\n        const current = stateRef.current;\n        for (const s of STATES) weights[s] = s === current ? 1 : 0;\n        draw(0, levelAt(0), SCALE[current]);\n      };\n      redrawRef.current();\n      return () => {\n        redrawRef.current = null;\n      };\n    }\n\n    let t = 0;\n    let amplitude = 0;\n    let scale = SCALE[stateRef.current];\n    let velocity = 0;\n    let last = performance.now();\n    let raf = 0;\n\n    const frame = (now: number) => {\n      const dt = Math.min((now - last) / 1000, 0.05);\n      last = now;\n      t += dt;\n\n      const current = stateRef.current;\n      const target = levelAt(t);\n      const rate = target > amplitude ? ATTACK : RELEASE;\n      amplitude += (target - amplitude) * (1 - Math.pow(1 - rate, dt * 60));\n\n      // per-state weights, so interrupting a change blends from what is on screen\n      const step = 1 - Math.pow(1 - BLEND, dt * 60);\n      for (const s of STATES) weights[s] += ((s === current ? 1 : 0) - weights[s]) * step;\n\n      velocity += (-STIFFNESS * (scale - SCALE[current]) - DAMPING * velocity) * dt;\n      scale += velocity * dt;\n\n      draw(t, amplitude, scale);\n      raf = requestAnimationFrame(frame);\n    };\n\n    // only animate while on screen\n    const io = new IntersectionObserver(([entry]) => {\n      cancelAnimationFrame(raf);\n      if (entry?.isIntersecting) {\n        last = performance.now();\n        raf = requestAnimationFrame(frame);\n      }\n    });\n    io.observe(stage);\n\n    return () => {\n      io.disconnect();\n      cancelAnimationFrame(raf);\n    };\n    // state and level stay out of the deps on purpose: the loop retargets, it never restarts\n  }, [size, satellites]);\n\n  useEffect(() => {\n    redrawRef.current?.();\n  }, [state, level]);\n\n  return (\n    <div\n      data-state={state}\n      style={{ gap, ...style }}\n      className={cn(\"relative inline-flex flex-col items-center select-none\", ACCENTS[variant] ?? ACCENTS.calamansi, className)}\n      {...props}\n    >\n      {/*\n        The fuse: blur the circles so neighbouring alphas bleed together, ramp the\n        alpha so the bleed becomes solid body, displace the edge with a little noise\n        so it reads as liquid, then trace a hairline rim inside the merged shape. The\n        cast shadow is a drop-shadow on the whole svg, so it follows the goo, necks and all.\n      */}\n      <svg\n        ref={stageRef}\n        aria-hidden=\"true\"\n        width={size}\n        height={size}\n        overflow=\"visible\"\n        className=\"block overflow-visible\"\n        style={{ color, filter: bare ? undefined : shadow }}\n      >\n        <defs>\n          <filter\n            id={filterId}\n            filterUnits=\"userSpaceOnUse\"\n            x={-size / 2}\n            y={-size / 2}\n            width={size * 2}\n            height={size * 2}\n            colorInterpolationFilters=\"sRGB\"\n          >\n            <feGaussianBlur in=\"SourceGraphic\" stdDeviation={blur} result=\"blur\" />\n            <feColorMatrix\n              in=\"blur\"\n              type=\"matrix\"\n              values={`1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 ${threshold} ${-threshold / 2}`}\n              result=\"goo\"\n            />\n            <feTurbulence type=\"fractalNoise\" baseFrequency={wavinessFreq} numOctaves={1} seed={3} result=\"noise\" />\n            <feDisplacementMap in=\"goo\" in2=\"noise\" scale={wave * 2} xChannelSelector=\"R\" yChannelSelector=\"G\" result=\"body\" />\n            <feMorphology in=\"body\" operator=\"erode\" radius={bare ? 0 : 1} result=\"inner\" />\n            <feComposite in=\"body\" in2=\"inner\" operator=\"out\" result=\"rimMask\" />\n            <feFlood floodColor=\"#ffffff\" floodOpacity={bare ? 0 : 0.22} />\n            <feComposite in2=\"rimMask\" operator=\"in\" result=\"rim\" />\n            <feMerge>\n              <feMergeNode in=\"body\" />\n              <feMergeNode in=\"rim\" />\n            </feMerge>\n          </filter>\n        </defs>\n        <g filter={gooey ? `url(#${filterId})` : undefined} fill=\"currentColor\">\n          {Array.from({ length: satellites + 1 }, (_, index) => {\n            const lobe = seed[index];\n            return (\n              <circle\n                key={index}\n                ref={(node) => {\n                  lobeRefs.current[index] = node;\n                }}\n                cx={lobe?.x ?? size / 2}\n                cy={lobe?.y ?? size / 2}\n                r={lobe?.r ?? 0}\n              />\n            );\n          })}\n        </g>\n      </svg>\n\n      {caption && (\n        <span\n          role=\"status\"\n          aria-live=\"polite\"\n          className=\"rounded-full bg-current/10 px-2.5 py-1 text-[10px] font-semibold tracking-[0.16em] text-muted-foreground uppercase ring-1 ring-current/20\"\n        >\n          {labels?.[state] ?? LABELS[state]}\n        </span>\n      )}\n    </div>\n  );\n}\n"}]}