{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A beautiful animated orb component inspired by Siri's visual design.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  type MotionStyle,\n  motion,\n  type TargetAndTransition,\n  type Transition,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type AIAmplitude,\n  type AIState,\n  getAIStateMotion,\n  useAmplitudeValue,\n} from \"../ai-core\";\n\nconst SIZE_THRESHOLD_SMALL = 50;\nconst SIZE_THRESHOLD_TINY = 30;\nconst SIZE_THRESHOLD_MEDIUM = 100;\nconst BLUR_MULTIPLIER_SMALL = 0.008;\nconst BLUR_MIN_SMALL = 1;\nconst BLUR_MULTIPLIER_LARGE = 0.015;\nconst BLUR_MIN_LARGE = 4;\nconst CONTRAST_MULTIPLIER_SMALL = 0.004;\nconst CONTRAST_MIN_SMALL = 1.2;\nconst CONTRAST_MULTIPLIER_LARGE = 0.008;\nconst CONTRAST_MIN_LARGE = 1.5;\nconst DOT_SIZE_MULTIPLIER_SMALL = 0.004;\nconst DOT_SIZE_MIN_SMALL = 0.05;\nconst DOT_SIZE_MULTIPLIER_LARGE = 0.008;\nconst DOT_SIZE_MIN_LARGE = 0.1;\nconst SHADOW_MULTIPLIER_SMALL = 0.004;\nconst SHADOW_MIN_SMALL = 0.5;\nconst SHADOW_MULTIPLIER_LARGE = 0.008;\nconst SHADOW_MIN_LARGE = 2;\nconst MASK_RADIUS_TINY = \"0%\";\nconst MASK_RADIUS_SMALL = \"5%\";\nconst MASK_RADIUS_MEDIUM = \"15%\";\nconst MASK_RADIUS_LARGE = \"25%\";\nconst CONTRAST_TINY = 1.1;\nconst CONTRAST_MULTIPLIER_FINAL = 1.2;\nconst CONTRAST_MIN_FINAL = 1.3;\n\n/** Loud audio tightens the gradient, which reads as the orb \"focusing\". */\nconst AMPLITUDE_BLUR_FALLOFF = 0.45;\n/** Amplitude adds at most 12% of extra size on top of the state scale. */\nconst AMPLITUDE_SCALE_GAIN = 0.12;\n/** Single lateral nudge used to signal `error`, well under 200ms. */\nconst ERROR_SHAKE_KEYFRAMES = [0, -3, 3, 0];\nconst ERROR_SHAKE_DURATION = 0.18;\nconst EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const;\nconst GLOW_BLUR_RATIO = 0.28;\nconst GLOW_MAX_OPACITY = 0.7;\n/** Depth rim, as a fraction of the orb size. */\nconst RIM_RATIO = 0.06;\nconst RIM_MIN = 1.5;\n/** Sheen drift period at rest, before the state speed divides it. */\nconst DRIFT_BASE_SECONDS = 12;\n/** `idle` breathes: a slow, shallow scale cycle that never draws attention. */\nconst BREATHE_SCALE = [1, 1.035, 1];\nconst BREATHE_SECONDS = 5.5;\nconst SPRING_DEFAULT: Transition = {\n  bounce: 0.1,\n  duration: 0.25,\n  type: \"spring\",\n};\n\nexport interface SiriOrbProps {\n  /**\n   * Live audio level, 0–1. Pass the `MotionValue` from `useAudioAmplitude` so\n   * the signal never re-renders React.\n   */\n  amplitude?: AIAmplitude;\n  /** Ambient rotation period in seconds, before the state speed multiplier. */\n  animationDuration?: number;\n  className?: string;\n  colors?: {\n    bg?: string;\n    c1?: string;\n    c2?: string;\n    c3?: string;\n    /** Fourth mesh stop. More stops means fewer visible repeats per rotation. */\n    c4?: string;\n  };\n  size?: string;\n  /** Shared AI state driving speed, scale, saturation and reactivity. */\n  state?: AIState;\n}\n\nconst SiriOrb: React.FC<SiriOrbProps> = ({\n  size = \"192px\",\n  className,\n  colors,\n  animationDuration = 20,\n  amplitude,\n  state = \"idle\",\n}) => {\n  const shouldReduceMotion = useReducedMotion();\n  const amplitudeValue = useAmplitudeValue(amplitude);\n  const stateMotion = getAIStateMotion(state);\n\n  /**\n   * Higher chroma than the original pastels.\n   *\n   * The first palette sat at chroma 0.12–0.15, which washes out once the mesh is\n   * blurred and the dot pattern is overlaid on top. Pushing to ~0.2 and adding a\n   * saturate() pass to the mesh is what makes the colour survive all that.\n   */\n  const defaultColors = {\n    bg: \"oklch(92% 0.03 300)\",\n    c1: \"oklch(68% 0.21 350)\", // Pink\n    c2: \"oklch(70% 0.18 210)\", // Blue\n    c3: \"oklch(66% 0.2 285)\", // Violet\n    c4: \"oklch(72% 0.19 325)\", // Magenta, the fourth stop for variety\n  };\n\n  const finalColors = { ...defaultColors, ...colors };\n  // The bloom stays in the orb's own palette — see the note in ai-orb-aura.\n  const glowColor = finalColors.c2;\n\n  // Extract numeric value from size for calculations\n  const sizeValue = Number.parseInt(size.replace(\"px\", \"\"), 10);\n\n  // Responsive calculations based on size\n  const blurAmount =\n    sizeValue < SIZE_THRESHOLD_SMALL\n      ? Math.max(sizeValue * BLUR_MULTIPLIER_SMALL, BLUR_MIN_SMALL) // Reduced blur for small sizes\n      : Math.max(sizeValue * BLUR_MULTIPLIER_LARGE, BLUR_MIN_LARGE);\n\n  const contrastAmount =\n    sizeValue < SIZE_THRESHOLD_SMALL\n      ? Math.max(sizeValue * CONTRAST_MULTIPLIER_SMALL, CONTRAST_MIN_SMALL) // Reduced contrast for small sizes\n      : Math.max(sizeValue * CONTRAST_MULTIPLIER_LARGE, CONTRAST_MIN_LARGE);\n\n  const dotSize =\n    sizeValue < SIZE_THRESHOLD_SMALL\n      ? Math.max(sizeValue * DOT_SIZE_MULTIPLIER_SMALL, DOT_SIZE_MIN_SMALL) // Smaller dots for small sizes\n      : Math.max(sizeValue * DOT_SIZE_MULTIPLIER_LARGE, DOT_SIZE_MIN_LARGE);\n\n  const shadowSpread =\n    sizeValue < SIZE_THRESHOLD_SMALL\n      ? Math.max(sizeValue * SHADOW_MULTIPLIER_SMALL, SHADOW_MIN_SMALL) // Reduced shadow for small sizes\n      : Math.max(sizeValue * SHADOW_MULTIPLIER_LARGE, SHADOW_MIN_LARGE);\n\n  // Adjust mask radius based on size to reduce black center in small sizes\n  const getMaskRadius = (value: number) => {\n    if (value < SIZE_THRESHOLD_TINY) {\n      return MASK_RADIUS_TINY;\n    }\n    if (value < SIZE_THRESHOLD_SMALL) {\n      return MASK_RADIUS_SMALL;\n    }\n    if (value < SIZE_THRESHOLD_MEDIUM) {\n      return MASK_RADIUS_MEDIUM;\n    }\n    return MASK_RADIUS_LARGE;\n  };\n\n  const maskRadius = getMaskRadius(sizeValue);\n\n  // Use more subtle contrast for very small sizes\n  const getFinalContrast = (value: number) => {\n    if (value < SIZE_THRESHOLD_TINY) {\n      return CONTRAST_TINY; // Very subtle contrast for tiny sizes\n    }\n    if (value < SIZE_THRESHOLD_SMALL) {\n      return Math.max(\n        contrastAmount * CONTRAST_MULTIPLIER_FINAL,\n        CONTRAST_MIN_FINAL\n      ); // Reduced contrast for small sizes\n    }\n    return contrastAmount;\n  };\n\n  const finalContrast = getFinalContrast(sizeValue);\n\n  // Reactivity is gated by the state preset: `thinking` barely listens so the\n  // orb keeps churning internally instead of throbbing with room noise.\n  const reactivity = shouldReduceMotion ? 0 : stateMotion.reactivity;\n\n  const reactiveBlur = useTransform(amplitudeValue, (level) => {\n    const focus = 1 - level * reactivity * AMPLITUDE_BLUR_FALLOFF;\n    return `${blurAmount * focus}px`;\n  });\n\n  const reactiveScale = useTransform(\n    amplitudeValue,\n    (level) => stateMotion.scale + level * reactivity * AMPLITUDE_SCALE_GAIN\n  );\n\n  const loopDuration = shouldReduceMotion\n    ? animationDuration\n    : animationDuration / stateMotion.speed;\n\n  // Rim thickness scales with the orb, so the lit edge reads the same at 24px\n  // and at 240px instead of swallowing the small one.\n  const rim = Math.max(sizeValue * RIM_RATIO, RIM_MIN);\n  // The sheen drifts slower than the mesh rotates; a busier state speeds it up\n  // a touch without matching the rotation, which would look mechanical.\n  const driftDuration = (DRIFT_BASE_SECONDS / (1 + stateMotion.speed)) * 2;\n\n  const getRootAnimate = (): TargetAndTransition => {\n    if (shouldReduceMotion) {\n      return { scale: 1, x: 0 };\n    }\n    if (state === \"error\") {\n      return { scale: 1, x: ERROR_SHAKE_KEYFRAMES };\n    }\n    if (stateMotion.motif === \"breathe\") {\n      return { scale: BREATHE_SCALE, x: 0 };\n    }\n    return { scale: 1, x: 0 };\n  };\n\n  const getRootTransition = (): Transition => {\n    if (shouldReduceMotion) {\n      return { duration: 0 };\n    }\n    if (state === \"error\") {\n      return { duration: ERROR_SHAKE_DURATION, ease: EASE_IN_OUT };\n    }\n    if (stateMotion.motif === \"breathe\") {\n      return {\n        duration: BREATHE_SECONDS,\n        ease: EASE_IN_OUT,\n        repeat: Number.POSITIVE_INFINITY,\n      };\n    }\n    return SPRING_DEFAULT;\n  };\n\n  return (\n    // The gradient disc clips its own overflow, so the bloom and the status\n    // motif have to live in a wrapper rather than inside it.\n    <motion.div\n      animate={getRootAnimate()}\n      className={cn(\"relative\", className)}\n      style={\n        {\n          \"--orb-size\": size,\n          height: size,\n          width: size,\n        } as MotionStyle\n      }\n      transition={getRootTransition()}\n    >\n      {/* Bloom tinted by the state's semantic accent. */}\n      <motion.div\n        animate={{ opacity: stateMotion.glow * GLOW_MAX_OPACITY }}\n        className=\"absolute rounded-full\"\n        style={{\n          background: `radial-gradient(circle at 50% 50%, ${glowColor} 0%, transparent 64%)`,\n          filter: `blur(calc(var(--orb-size) * ${GLOW_BLUR_RATIO}))`,\n          inset: \"-12%\",\n        }}\n        transition={shouldReduceMotion ? { duration: 0 } : SPRING_DEFAULT}\n      />\n\n      <motion.div\n        className=\"siri-orb\"\n        style={\n          {\n            \"--animation-duration\": `${loopDuration}s`,\n            \"--bg\": finalColors.bg,\n            \"--blur-amount\": reactiveBlur,\n            \"--c1\": finalColors.c1,\n            \"--c2\": finalColors.c2,\n            \"--c3\": finalColors.c3,\n            \"--c4\": finalColors.c4,\n            \"--contrast-amount\": finalContrast,\n            \"--dot-size\": `${dotSize}px`,\n            \"--drift-duration\": `${driftDuration}s`,\n            \"--mask-radius\": maskRadius,\n            \"--rim\": `${rim}px`,\n            \"--shadow-spread\": `${shadowSpread}px`,\n            // Saturation and hue only affect the gradient disc. Applying them\n            // to the whole component would desaturate the semantic accent too,\n            // and `error` would lose the very red that identifies it.\n            filter: `saturate(${stateMotion.saturation}) hue-rotate(${stateMotion.hueRotate}deg)`,\n            height: \"100%\",\n            scale: reactiveScale,\n            width: \"100%\",\n          } as MotionStyle\n        }\n      >\n        {/* Specular sheen and depth rim. The mesh alone reads as a flat blurred\n            disc; a drifting highlight plus a lit top edge and shaded bottom are\n            what make it read as a sphere. */}\n        <span aria-hidden=\"true\" className=\"siri-orb-layer siri-orb-sheen\" />\n        <span aria-hidden=\"true\" className=\"siri-orb-layer siri-orb-rim\" />\n        <style>{`\n        @property --angle {\n          syntax: \"<angle>\";\n          inherits: false;\n          initial-value: 0deg;\n        }\n\n        .siri-orb {\n          display: grid;\n          grid-template-areas: \"stack\";\n          overflow: hidden;\n          border-radius: 50%;\n          position: relative;\n          isolation: isolate;\n        }\n\n        .siri-orb::before,\n        .siri-orb::after,\n        .siri-orb > .siri-orb-layer {\n          content: \"\";\n          display: block;\n          grid-area: stack;\n          width: 100%;\n          height: 100%;\n          border-radius: 50%;\n        }\n\n        /* Glassy highlight that drifts, so the sheen never sits still enough to\n           read as a printed-on gradient. */\n        .siri-orb-sheen {\n          background:\n            radial-gradient(circle at 30% 24%, hsl(0 0% 100% / 0.32), transparent 34%),\n            radial-gradient(circle at 72% 80%, hsl(0 0% 100% / 0.07), transparent 48%);\n          mix-blend-mode: screen;\n          animation: siri-drift var(--drift-duration) ease-in-out infinite alternate;\n        }\n\n        /* Lit top edge, shaded bottom, thin inner ring. */\n        .siri-orb-rim {\n          box-shadow:\n            inset 0 0 0 1px hsl(0 0% 100% / 0.16),\n            inset 0 calc(var(--rim) * 1) calc(var(--rim) * 2) hsl(0 0% 100% / 0.22),\n            inset 0 calc(var(--rim) * -1.2) calc(var(--rim) * 2.4) hsl(0 0% 0% / 0.4);\n          pointer-events: none;\n        }\n\n        @keyframes siri-drift {\n          0% { transform: translate(-6%, -4%) scale(1.05); }\n          100% { transform: translate(7%, 6%) scale(1.12); }\n        }\n\n        .siri-orb::before {\n          background:\n            conic-gradient(\n              from calc(var(--angle) * 2) at 25% 70%,\n              var(--c3),\n              transparent 20% 80%,\n              var(--c3)\n            ),\n            conic-gradient(\n              from calc(var(--angle) * 2) at 45% 75%,\n              var(--c2),\n              transparent 30% 60%,\n              var(--c2)\n            ),\n            conic-gradient(\n              from calc(var(--angle) * -3) at 80% 20%,\n              var(--c1),\n              transparent 40% 60%,\n              var(--c1)\n            ),\n            conic-gradient(\n              from calc(var(--angle) * 1.5) at 60% 35%,\n              var(--c4),\n              transparent 25% 75%,\n              var(--c4)\n            ),\n            conic-gradient(\n              from calc(var(--angle) * 2) at 15% 5%,\n              var(--c2),\n              transparent 10% 90%,\n              var(--c2)\n            ),\n            conic-gradient(\n              from calc(var(--angle) * 1) at 20% 80%,\n              var(--c1),\n              transparent 10% 90%,\n              var(--c1)\n            ),\n            conic-gradient(\n              from calc(var(--angle) * -2) at 85% 10%,\n              var(--c3),\n              transparent 20% 80%,\n              var(--c3)\n            );\n          box-shadow: inset var(--bg) 0 0 var(--shadow-spread)\n            calc(var(--shadow-spread) * 0.2);\n          /* The saturate() pass is what keeps the colour alive after the blur\n             and the overlaid dot pattern have both eaten into it. */\n          filter: blur(var(--blur-amount)) contrast(var(--contrast-amount))\n            saturate(1.4);\n          animation: rotate var(--animation-duration) linear infinite;\n        }\n\n        .siri-orb::after {\n          background-image: radial-gradient(\n            circle at center,\n            var(--bg) var(--dot-size),\n            transparent var(--dot-size)\n          );\n          background-size: calc(var(--dot-size) * 2) calc(var(--dot-size) * 2);\n          backdrop-filter: blur(calc(var(--blur-amount) * 2))\n            contrast(calc(var(--contrast-amount) * 2));\n          mix-blend-mode: overlay;\n        }\n\n        /* Apply mask only when radius is greater than 0 */\n        .siri-orb[style*=\"--mask-radius: 0%\"]::after {\n          mask-image: none;\n        }\n\n        .siri-orb:not([style*=\"--mask-radius: 0%\"])::after {\n          mask-image: radial-gradient(\n            black var(--mask-radius),\n            transparent 75%\n          );\n        }\n\n        @keyframes rotate {\n          to {\n            --angle: 360deg;\n          }\n        }\n\n        @media (prefers-reduced-motion: reduce) {\n          .siri-orb::before,\n          .siri-orb-sheen {\n            animation: none;\n          }\n        }\n      `}</style>\n      </motion.div>\n    </motion.div>\n  );\n};\n\nexport default SiriOrb;\n","path":"index.tsx","target":"components/smoothui/siri-orb/index.tsx","type":"registry:ui"}],"name":"siri-orb","registryDependencies":["https://smoothui.dev/r/ai-core.json"],"title":"Siri Orb","type":"registry:ui"}