{"$schema":"https://ui.shadcn.com/schema/registry.json","homepage":"https://uicapsule.com/ui/dynamic-ai-composer","name":"dynamic-ai-composer","type":"registry:block","author":"Kaiyu Hsu <uicapsule@kyh.io>","dependencies":["lucide-react","motion"],"devDependencies":[],"registryDependencies":[],"files":[{"type":"registry:file","path":"/dynamic-ai-composer.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { ArrowUp, Check, Mic, Plus, Sparkles, X } from \"lucide-react\";\nimport { AnimatePresence, motion, useAnimate, type Transition } from \"motion/react\";\n\ntype Mode = \"idle\" | \"input\" | \"listening\" | \"thinking\" | \"responding\";\n\ntype Exchange = {\n  prompt: string;\n  words: string[];\n};\n\nconst VOICE_PROMPTS = [\n  \"What makes a voice interface feel alive?\",\n  \"How should an input morph between states?\",\n] as const;\n\nconst RESPONSES = [\n  \"Great voice UI breathes with you. The waveform should track real amplitude, the container should swell slightly as you speak, and silence should feel like the interface leaning in — not a dead sensor waiting for a timeout.\",\n  \"One container, many shapes. Keep the border radius continuous, crossfade contents through a light blur, and let a spring drive every size change so each state feels like the same object stretching, never a cut between screens.\",\n  \"Morphing earns trust when nothing teleports. Anchor the capsule in place, animate width and height from the same origin, and stagger content in a beat after the container settles so the shape reads first and the details second.\",\n] as const;\n\nconst SPRING: Transition = { type: \"spring\", duration: 0.55, bounce: 0.3 };\n\nconst WIDTHS = {\n  idle: \"w-[260px]\",\n  input: \"w-[min(400px,calc(100vw-48px))]\",\n  listening: \"w-[300px]\",\n  thinking: \"w-[200px]\",\n  responding: \"w-[min(420px,calc(100vw-48px))]\",\n} satisfies Record<Mode, string>;\n\nconst pickIndex = (length: number) => Math.floor(Math.random() * length);\n\nconst BAR_COUNT = 21;\n\nconst ListeningWave = () => (\n  <div className=\"flex h-8 flex-1 items-center justify-center gap-[3px]\">\n    {Array.from({ length: BAR_COUNT }, (_, index) => {\n      const envelope = 0.3 + 0.7 * Math.sin((index / (BAR_COUNT - 1)) * Math.PI);\n      const wobble = 0.45 + (Math.sin(index * 12.9898) * 0.5 + 0.5) * 0.55;\n      return (\n        <motion.div\n          key={index}\n          className=\"h-7 w-[3px] rounded-full bg-white\"\n          animate={{ scaleY: [0.12, envelope * wobble, 0.22, envelope, 0.12] }}\n          transition={{\n            duration: 0.9 + (index % 5) * 0.13,\n            repeat: Infinity,\n            ease: \"easeInOut\",\n            delay: (index % 7) * 0.07,\n          }}\n        />\n      );\n    })}\n  </div>\n);\n\nconst ThinkingSweep = () => (\n  <div aria-hidden className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-[24px]\">\n    <motion.div\n      className=\"absolute inset-[-150%] bg-[conic-gradient(from_0deg,transparent_0%,transparent_62%,#a78bfa_80%,#67e8f9_90%,transparent_100%)]\"\n      animate={{ rotate: 360 }}\n      transition={{ duration: 1.6, ease: \"linear\", repeat: Infinity }}\n    />\n    <div className=\"absolute inset-[1.5px] rounded-[22.5px] bg-neutral-900\" />\n  </div>\n);\n\nexport const DynamicAiComposer = () => {\n  const [mode, setMode] = useState<Mode>(\"idle\");\n  const [draft, setDraft] = useState(\"\");\n  const [exchange, setExchange] = useState<Exchange | null>(null);\n  const [streamedCount, setStreamedCount] = useState(0);\n  const [elapsed, setElapsed] = useState(0);\n  const [scope, animateShake] = useAnimate();\n\n  const textareaRef = useRef<HTMLTextAreaElement | null>(null);\n  const timeoutsRef = useRef(new Set<ReturnType<typeof setTimeout>>());\n\n  // Timers are tracked so unmount can cancel the in-flight stream; each one drops\n  // itself from the set as it fires so a long session cannot accumulate dead ids.\n  const queue = useCallback((callback: () => void, delay: number) => {\n    const id = setTimeout(() => {\n      timeoutsRef.current.delete(id);\n      callback();\n    }, delay);\n    timeoutsRef.current.add(id);\n  }, []);\n\n  useEffect(() => {\n    const timeouts = timeoutsRef.current;\n    return () => {\n      timeouts.forEach(clearTimeout);\n    };\n  }, []);\n\n  useEffect(() => {\n    if (mode === \"input\") {\n      textareaRef.current?.focus();\n    }\n  }, [mode]);\n\n  useEffect(() => {\n    if (mode !== \"listening\") return;\n    setElapsed(0);\n    const interval = setInterval(() => {\n      setElapsed((previous) => previous + 1);\n    }, 1000);\n    return () => clearInterval(interval);\n  }, [mode]);\n\n  const submit = useCallback(\n    (prompt: string) => {\n      const response = RESPONSES[pickIndex(RESPONSES.length)];\n      if (!response) return;\n      const words = response.split(\" \");\n      setExchange({ prompt, words });\n      setStreamedCount(0);\n      setMode(\"thinking\");\n\n      queue(() => {\n        setMode(\"responding\");\n        const step = (index: number) => {\n          setStreamedCount(index + 1);\n          if (index + 1 < words.length) {\n            queue(() => step(index + 1), 60 + ((index * 37) % 60));\n          }\n        };\n        queue(() => step(0), 250);\n      }, 1500);\n    },\n    [queue],\n  );\n\n  const handleSend = useCallback(() => {\n    const prompt = draft.trim();\n    if (!prompt) {\n      animateShake(scope.current, { x: [0, -10, 10, -6, 6, 0] }, { duration: 0.4 });\n      return;\n    }\n    setDraft(\"\");\n    submit(prompt);\n  }, [animateShake, draft, scope, submit]);\n\n  const handleVoiceConfirm = useCallback(() => {\n    const prompt = VOICE_PROMPTS[pickIndex(VOICE_PROMPTS.length)];\n    if (!prompt) return;\n    submit(prompt);\n  }, [submit]);\n\n  const reset = useCallback(() => {\n    setExchange(null);\n    setStreamedCount(0);\n    setMode(\"idle\");\n  }, []);\n\n  const isStreamDone = exchange !== null && streamedCount >= exchange.words.length;\n\n  // The gradient glow marks activity: every state change fires one pulse, and\n  // loading states (thinking, streaming a response) keep pulsing until done.\n  const isLoading = mode === \"thinking\" || (mode === \"responding\" && !isStreamDone);\n  const glowKey = `${mode}-${isLoading ? \"loading\" : \"settled\"}`;\n\n  const content = (() => {\n    switch (mode) {\n      case \"idle\":\n        return (\n          <div className=\"flex h-12 items-center gap-1 pr-2 pl-4\">\n            <button\n              type=\"button\"\n              onClick={() => setMode(\"input\")}\n              className=\"flex h-full flex-1 items-center gap-2.5 text-left\"\n            >\n              <Sparkles className=\"size-4 text-white/50\" />\n              <span className=\"text-sm text-white/45\">Ask anything</span>\n            </button>\n            <button\n              type=\"button\"\n              aria-label=\"Start voice input\"\n              onClick={() => setMode(\"listening\")}\n              className=\"grid size-8 place-items-center rounded-full text-white/60 transition-colors hover:bg-white/10 hover:text-white\"\n            >\n              <Mic className=\"size-4\" />\n            </button>\n          </div>\n        );\n      case \"input\":\n        return (\n          <div className=\"flex flex-col\">\n            <textarea\n              ref={textareaRef}\n              rows={2}\n              value={draft}\n              onChange={(event) => setDraft(event.target.value)}\n              onKeyDown={(event) => {\n                if (event.key === \"Enter\" && !event.shiftKey) {\n                  event.preventDefault();\n                  handleSend();\n                }\n                if (event.key === \"Escape\") {\n                  setMode(\"idle\");\n                }\n              }}\n              placeholder=\"Ask anything…\"\n              className=\"w-full resize-none bg-transparent px-4 pt-3.5 text-sm leading-6 text-white outline-none placeholder:text-white/40\"\n            />\n            <div className=\"flex items-center gap-1 p-2\">\n              <button\n                type=\"button\"\n                aria-label=\"Add attachment\"\n                className=\"grid size-8 place-items-center rounded-full text-white/50 transition-colors hover:bg-white/10 hover:text-white\"\n              >\n                <Plus className=\"size-4\" />\n              </button>\n              <div className=\"flex-1\" />\n              <button\n                type=\"button\"\n                aria-label=\"Start voice input\"\n                onClick={() => setMode(\"listening\")}\n                className=\"grid size-8 place-items-center rounded-full text-white/50 transition-colors hover:bg-white/10 hover:text-white\"\n              >\n                <Mic className=\"size-4\" />\n              </button>\n              <button\n                type=\"button\"\n                aria-label=\"Send message\"\n                onClick={handleSend}\n                className=\"grid size-8 place-items-center rounded-full bg-white text-neutral-900 transition-transform hover:scale-105 active:scale-95\"\n              >\n                <ArrowUp className=\"size-4\" />\n              </button>\n            </div>\n          </div>\n        );\n      case \"listening\":\n        return (\n          <div className=\"flex h-12 items-center gap-2 px-2\">\n            <button\n              type=\"button\"\n              aria-label=\"Cancel voice input\"\n              onClick={() => setMode(\"idle\")}\n              className=\"grid size-8 shrink-0 place-items-center rounded-full text-white/50 transition-colors hover:bg-white/10 hover:text-white\"\n            >\n              <X className=\"size-4\" />\n            </button>\n            <ListeningWave />\n            <span className=\"w-8 shrink-0 text-center text-xs tabular-nums text-white/50\">\n              {Math.floor(elapsed / 60)}:{String(elapsed % 60).padStart(2, \"0\")}\n            </span>\n            <button\n              type=\"button\"\n              aria-label=\"Confirm voice input\"\n              onClick={handleVoiceConfirm}\n              className=\"grid size-8 shrink-0 place-items-center rounded-full bg-white text-neutral-900 transition-transform hover:scale-105 active:scale-95\"\n            >\n              <Check className=\"size-4\" />\n            </button>\n          </div>\n        );\n      case \"thinking\":\n        return (\n          <div className=\"flex h-12 items-center justify-center gap-2.5 px-4\">\n            <Sparkles className=\"size-4 text-violet-300\" />\n            <motion.span\n              className=\"bg-[linear-gradient(90deg,rgba(255,255,255,0.25)_0%,rgba(255,255,255,0.95)_50%,rgba(255,255,255,0.25)_100%)] bg-[length:200%_100%] bg-clip-text text-sm text-transparent\"\n              animate={{ backgroundPosition: [\"150% 0%\", \"-150% 0%\"] }}\n              transition={{ duration: 1.4, repeat: Infinity, ease: \"linear\" }}\n            >\n              Thinking…\n            </motion.span>\n          </div>\n        );\n      case \"responding\":\n        return (\n          <div className=\"flex flex-col gap-2 px-4 py-3.5\">\n            <p className=\"flex items-center gap-2 text-xs text-white/40\">\n              <Sparkles className=\"size-3 shrink-0 text-violet-300/70\" />\n              <span className=\"truncate\">{exchange?.prompt}</span>\n            </p>\n            <p className=\"min-h-12 text-sm leading-6 text-white/90\">\n              {exchange?.words.slice(0, streamedCount).map((word, index) => (\n                <motion.span\n                  key={index}\n                  initial={{ opacity: 0, filter: \"blur(4px)\" }}\n                  animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                  transition={{ duration: 0.3 }}\n                >\n                  {word}{\" \"}\n                </motion.span>\n              ))}\n            </p>\n            <AnimatePresence>\n              {isStreamDone && (\n                <motion.div\n                  initial={{ opacity: 0, y: 4 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  className=\"flex justify-end\"\n                >\n                  <button\n                    type=\"button\"\n                    onClick={reset}\n                    className=\"rounded-full px-3 py-1.5 text-xs text-white/50 transition-colors hover:bg-white/10 hover:text-white\"\n                  >\n                    Ask again\n                  </button>\n                </motion.div>\n              )}\n            </AnimatePresence>\n          </div>\n        );\n    }\n  })();\n\n  return (\n    <div ref={scope} className=\"relative\">\n      <AnimatePresence>\n        <motion.div\n          key={glowKey}\n          aria-hidden\n          className=\"pointer-events-none absolute -inset-2 rounded-[32px] bg-[conic-gradient(from_0deg,#60a5fa,#a78bfa,#f472b6,#38bdf8,#60a5fa)] blur-xl\"\n          initial={{ opacity: 0 }}\n          animate={{ opacity: [0, 0.55, 0] }}\n          exit={{ opacity: 0, transition: { duration: 0.25 } }}\n          transition={{ duration: 1.2, ease: \"easeInOut\", repeat: isLoading ? Infinity : 0 }}\n        />\n      </AnimatePresence>\n\n      <motion.div\n        layout\n        style={{ borderRadius: 24 }}\n        transition={SPRING}\n        className={`relative overflow-hidden bg-neutral-900 shadow-2xl shadow-black/50 ring-1 ring-white/10 ${WIDTHS[mode]}`}\n      >\n        {mode === \"thinking\" && <ThinkingSweep />}\n        <motion.div\n          key={mode}\n          className=\"relative\"\n          initial={{ opacity: 0, scale: 0.92, filter: \"blur(6px)\" }}\n          animate={{\n            opacity: 1,\n            scale: 1,\n            filter: \"blur(0px)\",\n            transition: { ...SPRING, delay: 0.05 },\n          }}\n        >\n          {content}\n        </motion.div>\n      </motion.div>\n    </div>\n  );\n};\n","target":"uicapsule/dynamic-ai-composer/dynamic-ai-composer.tsx"},{"type":"registry:file","path":"/package.json","content":"{\n  \"name\": \"@uicapsule/dynamic-ai-composer\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"exports\": {\n    \"./preview\": \"./preview.tsx\"\n  },\n  \"scripts\": {\n    \"clean\": \"git clean -xdf .cache .turbo dist node_modules\"\n  },\n  \"dependencies\": {\n    \"lucide-react\": \"^1.35.0\",\n    \"motion\": \"^13.1.1\",\n    \"react\": \"catalog:\",\n    \"react-dom\": \"catalog:\"\n  },\n  \"devDependencies\": {\n    \"@types/react\": \"catalog:\",\n    \"@types/react-dom\": \"catalog:\"\n  }\n}\n","target":"uicapsule/dynamic-ai-composer/package.json"},{"type":"registry:file","path":"/preview.tsx","content":"\"use client\";\n\nimport { DynamicAiComposer } from \"./dynamic-ai-composer\";\n\nconst Preview = () => {\n  return (\n    <main className=\"flex h-dvh flex-col items-center justify-end overflow-hidden bg-[radial-gradient(circle_at_50%_20%,#1e293b_0%,#0b1120_45%,#020617_100%)] px-5 pb-6\">\n      <DynamicAiComposer />\n      <p className=\"mt-5 text-xs text-white/25\">Enter to send · tap the mic for voice</p>\n    </main>\n  );\n};\n\nexport default Preview;\n","target":"uicapsule/dynamic-ai-composer/preview.tsx"}]}