{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"voice-orb","type":"registry:block","title":"Voice Orb","description":"Immersive voice-mode orb: one continuous gradient sphere whose breathing, hue drift, echo rings and thinking sheen follow idle / listening / thinking / speaking states.","author":"UI Lab","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/voice-orb/index.tsx","type":"registry:component","target":"@components/motion/voice-orb/index.tsx","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/blocks/voice-orb\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { EASE_OUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type VoiceOrbState = \"idle\" | \"listening\" | \"thinking\" | \"speaking\";\n\nexport interface VoiceOrbProps {\n  /** Conversational state — drives breathing rhythm, hue drift, echo rings and the thinking sheen. */\n  state?: VoiceOrbState;\n  /** Orb diameter in pixels. */\n  size?: number;\n  className?: string;\n}\n\n// Brand blue (#339CFF — the library's accent, used elsewhere for focus rings)\n// carried as an rgb triplet so the glow shadow can share it at low alpha.\nconst GLOW_RGB = \"51, 156, 255\";\n\nconst ORB_BACKGROUND =\n  \"radial-gradient(circle at 30% 26%, #d9ecff 0%, #339CFF 38%, #1c5aa8 68%, #0a2540 100%)\";\n\nconst ORB_SHADOW = [\n  `0 0 64px rgba(${GLOW_RGB}, 0.45)`,\n  `0 0 130px rgba(${GLOW_RGB}, 0.22)`,\n  \"inset 0 -20px 42px rgba(4, 12, 26, 0.4)\",\n  \"inset 0 16px 28px rgba(255, 255, 255, 0.2)\",\n].join(\", \");\n\n/**\n * Per-state breathing rhythm — the ball's own continuous `animate` target.\n * These durations/eases are bespoke to the breathing/echo feel rather than UI\n * transitions, so they live as named constants instead of `lib/ease.ts`\n * tokens (which are tuned for <300ms UI moves and panel entrances): idle is\n * a slow, low-lit drift; listening/speaking sync the scale with a hue or\n * brightness pulse; thinking calms the breathing so the rotating sheen below\n * carries the motion instead.\n */\nconst BREATH: Record<\n  VoiceOrbState,\n  { scale: number[]; filter: string | string[]; duration: number }\n> = {\n  idle: {\n    scale: [1, 1.03, 1],\n    filter: \"brightness(0.85)\",\n    duration: 5,\n  },\n  listening: {\n    scale: [1, 1.06, 1],\n    filter: [\n      \"hue-rotate(0deg) brightness(1)\",\n      \"hue-rotate(15deg) brightness(1.05)\",\n      \"hue-rotate(0deg) brightness(1)\",\n    ],\n    duration: 2.4,\n  },\n  thinking: {\n    scale: [1, 1.02, 1],\n    filter: [\"brightness(0.95)\", \"brightness(1.05)\", \"brightness(0.95)\"],\n    duration: 3.2,\n  },\n  speaking: {\n    scale: [1, 1.09, 1.02, 1.12, 1],\n    filter: [\"brightness(1)\", \"brightness(1.15)\", \"brightness(1)\"],\n    duration: 1.6,\n  },\n};\n\n/** Expanding echo-ring, cloned twice with a stagger — a decelerating ripple radiating off the ball's edge. */\nfunction EchoRing({ duration, delay }: { duration: number; delay: number }) {\n  return (\n    <motion.span\n      aria-hidden\n      className=\"absolute inset-0 rounded-full border border-[#339CFF]/40\"\n      initial={{ scale: 1, opacity: 0.5 }}\n      animate={{ scale: 1.6, opacity: 0 }}\n      transition={{ duration, delay, repeat: Infinity, ease: \"easeOut\" }}\n    />\n  );\n}\n\n/**\n * Central voice-mode orb — a single continuous ball that never unmounts\n * across `idle` / `listening` / `thinking` / `speaking`; only the breathing\n * `animate` target changes, so the motion carries through state switches\n * instead of resetting. Echo rings (listening/speaking) and the thinking\n * sheen are the only pieces that mount/unmount — they spring in with\n * `SPRING_PANEL` and fade out with `EASE_OUT`, quicker out than in, matching\n * the library's exit convention. Fully static under `useReducedMotion()`: no\n * breathing, no rings, no rotation — just the plain gradient ball.\n *\n * Purely decorative (`aria-hidden`); pair it with your own status text for\n * the accessible state announcement.\n */\nexport function VoiceOrb({\n  state = \"idle\",\n  size = 160,\n  className,\n}: VoiceOrbProps) {\n  const reduce = useReducedMotion() ?? false;\n  const breath = BREATH[state];\n  const showRings = state === \"listening\" || state === \"speaking\";\n  const showSheen = state === \"thinking\";\n  // Speaking answers back faster than listening picks up — the echo halves in length.\n  const echoDuration = state === \"speaking\" ? 1.2 : 2;\n\n  return (\n    <div\n      aria-hidden\n      className={cn(\n        \"relative inline-flex items-center justify-center\",\n        className,\n      )}\n      style={{ width: size, height: size }}\n    >\n      <AnimatePresence>\n        {!reduce && showRings ? (\n          <motion.span\n            key=\"rings\"\n            className=\"absolute inset-0\"\n            initial={{ opacity: 0, scale: 0.85 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{\n              opacity: 0,\n              scale: 0.9,\n              transition: { duration: 0.15, ease: EASE_OUT },\n            }}\n            transition={SPRING_PANEL}\n          >\n            <EchoRing duration={echoDuration} delay={0} />\n            <EchoRing duration={echoDuration} delay={echoDuration / 2} />\n          </motion.span>\n        ) : null}\n      </AnimatePresence>\n\n      <motion.div\n        className=\"relative rounded-full\"\n        style={{\n          width: \"100%\",\n          height: \"100%\",\n          background: ORB_BACKGROUND,\n          boxShadow: ORB_SHADOW,\n        }}\n        animate={\n          reduce ? undefined : { scale: breath.scale, filter: breath.filter }\n        }\n        transition={\n          reduce\n            ? undefined\n            : { duration: breath.duration, repeat: Infinity, ease: \"easeInOut\" }\n        }\n      >\n        <AnimatePresence>\n          {!reduce && showSheen ? (\n            <motion.span\n              key=\"sheen\"\n              aria-hidden\n              className=\"absolute inset-0 overflow-hidden rounded-full mix-blend-overlay\"\n              style={{\n                background:\n                  \"conic-gradient(from 0deg, transparent 0deg, rgba(255,255,255,0.5) 50deg, transparent 130deg, transparent 360deg)\",\n              }}\n              initial={{ opacity: 0, scale: 0.85, rotate: 0 }}\n              animate={{ opacity: 1, scale: 1, rotate: 360 }}\n              exit={{\n                opacity: 0,\n                scale: 0.9,\n                transition: { duration: 0.15, ease: EASE_OUT },\n              }}\n              transition={{\n                opacity: SPRING_PANEL,\n                scale: SPRING_PANEL,\n                rotate: { duration: 6, repeat: Infinity, ease: \"linear\" },\n              }}\n            />\n          ) : null}\n        </AnimatePresence>\n      </motion.div>\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}