{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"Cartoon character orb whose expression carries the AI state — cursor-following gaze, natural blinks, thinking saccades, happy arcs and spiral eyes.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  motion,\n  useAnimationControls,\n  useReducedMotion,\n  useSpring,\n} from \"motion/react\";\nimport { useCallback, useEffect, useId, useRef } from \"react\";\nimport {\n  type AIAmplitude,\n  type AIState,\n  getAIStateMotion,\n  useAmplitudeValue,\n} from \"../ai-core\";\n\nconst VIEWBOX = 100;\nconst CENTER = VIEWBOX / 2;\nconst EYE_OFFSET = 16;\nconst EYE_Y = 44;\nconst EYE_WIDTH = 11;\nconst EYE_HEIGHT = 26;\nconst EYE_RADIUS = 5.5;\n/** How far the pupils can travel from centre, in viewBox units. */\nconst GAZE_RANGE = 5.5;\n/** Cursor distance, in px, at which the gaze reaches full deflection. */\nconst GAZE_FALLOFF = 220;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst EASE_IN = [0.4, 0, 1, 1] as const;\n/** A ~1.25-turn swirl; spun in place it reads as dizzy. */\nconst SPIRAL = \"M0 0C-0.6 -4 5 -5 6 -0.6C7 4.5 1 8 -4 6C-9 4.5 -9.5 -2 -6 -6\";\nconst BLINK_MIN_MS = 3200;\nconst BLINK_EXTRA_MS = 2600;\nconst DOUBLE_BLINK_CHANCE = 0.25;\n/** Thinking saccades: the eyes look away and up, the way people search. */\nconst SACCADE_MIN_MS = 700;\nconst SACCADE_EXTRA_MS = 700;\nconst SACCADE_TARGETS = [\n  { x: -1, y: -1 },\n  { x: 1, y: -1 },\n  { x: -0.6, y: -0.4 },\n  { x: 0.8, y: -0.9 },\n] as const;\n\nexport type AIOrbFaceProps = {\n  /** Accessible label. Omit to keep the character decorative. */\n  \"aria-label\"?: string;\n  /** Live audio level, 0–1. Widens the eyes and lifts the body while speaking. */\n  amplitude?: AIAmplitude;\n  className?: string;\n  colors?: { body?: string; bodyEdge?: string; feature?: string };\n  /**\n   * Follow the pointer with its gaze. Turn off inside dense UI where a dozen\n   * of these tracking the cursor would be noise.\n   */\n  gaze?: boolean;\n  /** Rendered size. Numbers are pixels. */\n  size?: number | string;\n  state?: AIState;\n};\n\nconst DEFAULT_COLORS = {\n  body: \"oklch(78% 0.14 280)\",\n  bodyEdge: \"oklch(70% 0.16 320)\",\n  feature: \"oklch(24% 0.03 280)\",\n};\n\n/**\n * A little character rather than an indicator.\n *\n * Status is carried by expression — the thing humans read fastest and the reason\n * a literal checkmark stuck onto an orb feels wrong. Squints while it thinks,\n * eyes wide while it listens, happy arcs when it finishes, spiral-eyed when it\n * breaks. The gaze and blink cadence are borrowed from the SmoothUI moai so the\n * two feel like the same creature.\n */\nconst AIOrbFace = ({\n  \"aria-label\": ariaLabel,\n  amplitude,\n  className,\n  colors,\n  gaze = true,\n  size = 128,\n  state = \"idle\",\n}: AIOrbFaceProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const amplitudeValue = useAmplitudeValue(amplitude);\n  const stateMotion = getAIStateMotion(state);\n  const finalColors = { ...DEFAULT_COLORS, ...colors };\n\n  // Gradient ids must be unique per instance, or a second face on the page\n  // silently repaints the first one's body.\n  const bodyGradientId = `${useId()}-body`;\n  const svgRef = useRef<SVGSVGElement | null>(null);\n  const leftLid = useAnimationControls();\n  const rightLid = useAnimationControls();\n  const bodyControls = useAnimationControls();\n\n  const gazeX = useSpring(0, { damping: 26, stiffness: 220 });\n  const gazeY = useSpring(0, { damping: 26, stiffness: 220 });\n\n  const resolvedSize = typeof size === \"number\" ? `${size}px` : size;\n\n  const isHappy = state === \"done\";\n  const isThinking = state === \"thinking\";\n  const isListening = state === \"listening\";\n  const isBroken = state === \"error\";\n  const eyesOpen = !(isHappy || isBroken);\n\n  // How open the eyes rest for this state.\n  //\n  // This drives the rect's `height`, never its `scaleY`. The blink owns scaleY\n  // outright — when both the squint and the blink wrote to scaleY they fought,\n  // and the eyes ended up stuck at whichever animation finished last.\n  const openScale = (() => {\n    if (isListening) {\n      return 1.15;\n    }\n    if (isThinking) {\n      return 0.62;\n    }\n    if (state === \"streaming\") {\n      return 0.88;\n    }\n    return 1;\n  })();\n\n  // A blink spans several awaits, so the component can unmount mid-blink — a\n  // route change, a state that hides the eyes. Animation controls throw if they\n  // are driven after unmount, so every leg checks first.\n  const mountedRef = useRef(false);\n  useEffect(() => {\n    mountedRef.current = true;\n    return () => {\n      mountedRef.current = false;\n    };\n  }, []);\n\n  // Snap shut, ease back open — an even-timed blink reads as a machine.\n  const blink = useCallback(\n    async (double = false) => {\n      const closeT = { duration: 0.07, ease: EASE_IN };\n      const openT = { duration: 0.16, ease: EASE_OUT };\n      if (!mountedRef.current) {\n        return;\n      }\n      await Promise.all([\n        leftLid.start({ scaleY: 0.08 }, closeT),\n        rightLid.start({ scaleY: 0.08 }, closeT),\n      ]);\n      if (!mountedRef.current) {\n        return;\n      }\n      await Promise.all([\n        leftLid.start({ scaleY: 1 }, double ? closeT : openT),\n        rightLid.start({ scaleY: 1 }, double ? closeT : openT),\n      ]);\n      if (double) {\n        await blink(false);\n      }\n    },\n    [leftLid, rightLid]\n  );\n\n  // Idle blinking, occasionally a double.\n  useEffect(() => {\n    if (shouldReduceMotion || !eyesOpen) {\n      return;\n    }\n    let timeout: ReturnType<typeof setTimeout>;\n    let mounted = true;\n    const schedule = () => {\n      timeout = setTimeout(\n        () => {\n          if (mounted) {\n            blink(Math.random() < DOUBLE_BLINK_CHANCE);\n          }\n          schedule();\n        },\n        BLINK_MIN_MS + Math.random() * BLINK_EXTRA_MS\n      );\n    };\n    schedule();\n    return () => {\n      mounted = false;\n      clearTimeout(timeout);\n    };\n  }, [blink, eyesOpen, shouldReduceMotion]);\n\n  // Gaze follows the pointer, except while thinking — then it looks away, which\n  // is exactly what makes \"thinking\" legible without any added graphic.\n  useEffect(() => {\n    if (!gaze || shouldReduceMotion || isThinking || !eyesOpen) {\n      return;\n    }\n    const handle = (event: PointerEvent) => {\n      const svg = svgRef.current;\n      if (!svg) {\n        return;\n      }\n      const rect = svg.getBoundingClientRect();\n      const dx = event.clientX - (rect.left + rect.width / 2);\n      const dy = event.clientY - (rect.top + rect.height / 2);\n      const distance = Math.sqrt(dx * dx + dy * dy);\n      const reach = Math.min(1, distance / GAZE_FALLOFF) * GAZE_RANGE;\n      const angle = Math.atan2(dy, dx);\n      gazeX.set(Math.cos(angle) * reach);\n      gazeY.set(Math.sin(angle) * reach);\n    };\n    window.addEventListener(\"pointermove\", handle);\n    return () => window.removeEventListener(\"pointermove\", handle);\n  }, [gaze, gazeX, gazeY, isThinking, eyesOpen, shouldReduceMotion]);\n\n  // Thinking saccades.\n  useEffect(() => {\n    if (!isThinking || shouldReduceMotion) {\n      return;\n    }\n    let timeout: ReturnType<typeof setTimeout>;\n    let mounted = true;\n    let index = 0;\n    const schedule = () => {\n      timeout = setTimeout(\n        () => {\n          if (!mounted) {\n            return;\n          }\n          const target = SACCADE_TARGETS[index % SACCADE_TARGETS.length];\n          index += 1;\n          gazeX.set(target.x * GAZE_RANGE);\n          gazeY.set(target.y * GAZE_RANGE);\n          schedule();\n        },\n        SACCADE_MIN_MS + Math.random() * SACCADE_EXTRA_MS\n      );\n    };\n    schedule();\n    return () => {\n      mounted = false;\n      clearTimeout(timeout);\n    };\n  }, [gazeX, gazeY, isThinking, shouldReduceMotion]);\n\n  // Error: one dizzy wobble.\n  useEffect(() => {\n    if (!isBroken) {\n      return;\n    }\n    gazeX.set(0);\n    gazeY.set(0);\n    if (shouldReduceMotion) {\n      return;\n    }\n    // The wobble plays once, but the spiral eyes stay for as long as the state\n    // is `error`. Recovering to a neutral face after a couple of seconds would\n    // leave a broken assistant looking fine.\n    bodyControls.start(\n      { rotate: [0, -11, 9, -7, 5, -3, 0], x: [0, -5, 4, -3, 2, -1, 0] },\n      { duration: 1.05, ease: [0.45, 0, 0.55, 1] }\n    );\n  }, [bodyControls, gazeX, gazeY, isBroken, shouldReduceMotion]);\n\n  // Done: a happy hop. The squash-and-stretch is the whole payload.\n  useEffect(() => {\n    if (!isHappy || shouldReduceMotion) {\n      return;\n    }\n    bodyControls.start(\n      {\n        scaleX: [1, 1.08, 0.94, 1.04, 0.99, 1],\n        scaleY: [1, 0.9, 1.08, 0.95, 1.02, 1],\n        y: [0, 3, -9, 0, -3, 0],\n      },\n      { duration: 0.85, ease: EASE_OUT, times: [0, 0.12, 0.4, 0.62, 0.82, 1] }\n    );\n  }, [bodyControls, isHappy, shouldReduceMotion]);\n\n  // Listening: the body breathes with the voice. Driven from the MotionValue so\n  // a 60fps audio signal never re-renders this component.\n  useEffect(() => {\n    if (shouldReduceMotion || !isListening) {\n      return;\n    }\n    const unsubscribe = amplitudeValue.on(\"change\", (level) => {\n      bodyControls.set({ scale: 1 + level * 0.07, y: -level * 2 });\n    });\n    return unsubscribe;\n  }, [amplitudeValue, bodyControls, isListening, shouldReduceMotion]);\n\n  // Reset the breathing here rather than in the effect above. Animation\n  // controls reject `set()` after unmount, and an unsubscribe cleanup is\n  // exactly when the component may already be gone.\n  useEffect(() => {\n    if (!(isListening || !mountedRef.current)) {\n      bodyControls.set({ scale: 1, y: 0 });\n    }\n  }, [bodyControls, isListening]);\n\n  const renderEye = (side: -1 | 1) => {\n    const x = CENTER + side * EYE_OFFSET - EYE_WIDTH / 2;\n    const controls = side === -1 ? leftLid : rightLid;\n    const height = EYE_HEIGHT * openScale;\n    // Keep the eye centred as it opens and closes, so a squint reads as lids\n    // meeting rather than the eye sliding up the face.\n    const y = EYE_Y + (EYE_HEIGHT - height) / 2;\n\n    return (\n      <motion.rect\n        animate={controls}\n        fill={finalColors.feature}\n        height={height}\n        initial={{ scaleY: 1 }}\n        rx={Math.min(EYE_RADIUS, height / 2)}\n        style={{\n          transformOrigin: `${x + EYE_WIDTH / 2}px ${y + height / 2}px`,\n          x: gazeX,\n          y: gazeY,\n        }}\n        transition={\n          shouldReduceMotion\n            ? { duration: 0 }\n            : { bounce: 0.1, duration: 0.25, type: \"spring\" }\n        }\n        width={EYE_WIDTH}\n        x={x}\n        y={y}\n      />\n    );\n  };\n\n  const renderHappyEye = (side: -1 | 1) => {\n    const cx = CENTER + side * EYE_OFFSET;\n    const y = EYE_Y + EYE_HEIGHT / 2;\n    return (\n      <motion.path\n        animate={{ pathLength: 1 }}\n        d={`M${cx - 8} ${y + 3} Q${cx} ${y - 11} ${cx + 8} ${y + 3}`}\n        fill=\"none\"\n        initial={shouldReduceMotion ? { pathLength: 1 } : { pathLength: 0 }}\n        stroke={finalColors.feature}\n        strokeLinecap=\"round\"\n        strokeWidth={7}\n        transition={\n          shouldReduceMotion\n            ? { duration: 0 }\n            : { delay: side === -1 ? 0 : 0.06, duration: 0.3, ease: EASE_OUT }\n        }\n      />\n    );\n  };\n\n  const renderDizzyEye = (side: -1 | 1) => (\n    <motion.path\n      animate={shouldReduceMotion ? undefined : { rotate: 360 * side }}\n      d={SPIRAL}\n      fill=\"none\"\n      stroke={finalColors.feature}\n      strokeLinecap=\"round\"\n      strokeWidth={3}\n      style={{\n        scale: 1.5,\n        x: CENTER + side * EYE_OFFSET,\n        y: EYE_Y + EYE_HEIGHT / 2,\n      }}\n      transition={{\n        duration: 2.4,\n        ease: \"linear\",\n        repeat: Number.POSITIVE_INFINITY,\n      }}\n    />\n  );\n\n  const renderMouth = () => {\n    if (isHappy) {\n      return (\n        <motion.path\n          animate={{ pathLength: 1 }}\n          d={`M${CENTER - 11} 76 Q${CENTER} 88 ${CENTER + 11} 76`}\n          fill=\"none\"\n          initial={shouldReduceMotion ? { pathLength: 1 } : { pathLength: 0 }}\n          stroke={finalColors.feature}\n          strokeLinecap=\"round\"\n          strokeWidth={5}\n          transition={\n            shouldReduceMotion\n              ? { duration: 0 }\n              : { duration: 0.32, ease: EASE_OUT }\n          }\n        />\n      );\n    }\n    if (isBroken) {\n      // A woozy wave, not a frown — it is confused, not scolding the user.\n      return (\n        <path\n          d={`M${CENTER - 12} 79 q6 -7 12 0 t12 0`}\n          fill=\"none\"\n          stroke={finalColors.feature}\n          strokeLinecap=\"round\"\n          strokeWidth={4}\n        />\n      );\n    }\n    if (isThinking) {\n      // Off-centre line: the universal \"hmm\".\n      return (\n        <motion.line\n          animate={{ x: [0, 3, 0] }}\n          stroke={finalColors.feature}\n          strokeLinecap=\"round\"\n          strokeWidth={4}\n          transition={\n            shouldReduceMotion\n              ? { duration: 0 }\n              : {\n                  duration: 2.4,\n                  ease: EASE_OUT,\n                  repeat: Number.POSITIVE_INFINITY,\n                }\n          }\n          x1={CENTER - 6}\n          x2={CENTER + 8}\n          y1={78}\n          y2={78}\n        />\n      );\n    }\n    return (\n      <line\n        stroke={finalColors.feature}\n        strokeLinecap=\"round\"\n        strokeWidth={4}\n        x1={CENTER - 7}\n        x2={CENTER + 7}\n        y1={78}\n        y2={78}\n      />\n    );\n  };\n\n  return (\n    <motion.svg\n      animate={bodyControls}\n      aria-hidden={ariaLabel ? undefined : true}\n      aria-label={ariaLabel}\n      className={cn(\"block overflow-visible\", className)}\n      ref={svgRef}\n      role={ariaLabel ? \"img\" : undefined}\n      style={{\n        filter: `saturate(${stateMotion.saturation})`,\n        height: resolvedSize,\n        width: resolvedSize,\n      }}\n      viewBox={`0 0 ${VIEWBOX} ${VIEWBOX}`}\n    >\n      <title>{ariaLabel ?? \"AI assistant character\"}</title>\n      <defs>\n        <radialGradient cx=\"35%\" cy=\"28%\" id={bodyGradientId} r=\"80%\">\n          <stop offset=\"0%\" stopColor={finalColors.body} />\n          <stop offset=\"100%\" stopColor={finalColors.bodyEdge} />\n        </radialGradient>\n      </defs>\n\n      <circle cx={CENTER} cy={CENTER} fill={`url(#${bodyGradientId})`} r={48} />\n      {/* One highlight is enough to make it a body rather than a flat disc. */}\n      <ellipse\n        cx={CENTER - 14}\n        cy={CENTER - 22}\n        fill=\"rgb(255 255 255 / 0.4)\"\n        rx={13}\n        ry={8}\n      />\n\n      {eyesOpen && renderEye(-1)}\n      {eyesOpen && renderEye(1)}\n      {isHappy && renderHappyEye(-1)}\n      {isHappy && renderHappyEye(1)}\n      {isBroken && renderDizzyEye(-1)}\n      {isBroken && renderDizzyEye(1)}\n      {renderMouth()}\n    </motion.svg>\n  );\n};\n\nexport default AIOrbFace;\n","path":"index.tsx","target":"components/smoothui/ai-orb-face/index.tsx","type":"registry:ui"}],"name":"ai-orb-face","registryDependencies":["https://smoothui.dev/r/ai-core.json"],"title":"Ai Orb Face","type":"registry:ui"}