{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-input",
  "title": "AI Input",
  "description": "AI prompt box with auto-growing textarea, cycling placeholders, file chips, model picker, toggle chips and a send button that morphs into stop.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://trytweenly.vercel.app/r/use-reduced-motion.json",
    "https://trytweenly.vercel.app/r/tweenly-theme.json"
  ],
  "files": [
    {
      "path": "registry/new-york/ai-input/ai-input.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useAnimationFrame, useMotionValue, type MotionValue, type Transition } from \"motion/react\"\nimport { ArrowUp, Check, ChevronDown, FileText, ImageIcon, Paperclip, Square, X } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { useReducedMotion } from \"@/registry/new-york/hooks/use-reduced-motion\"\n\nexport type AiInputStatus = \"idle\" | \"submitting\" | \"streaming\"\n\nexport interface AiInputToggle {\n  id: string\n  label: string\n  icon?: React.ReactNode\n}\n\nexport interface AiInputSubmit {\n  text: string\n  files: File[]\n  model: string | undefined\n  /** Ids of the toggles that are on. */\n  toggles: string[]\n}\n\nexport interface AiInputProps {\n  /** Controlled text. Default: undefined */\n  value?: string\n  /** Initial text when uncontrolled. Default: \"\" */\n  defaultValue?: string\n  /** Called whenever the text changes. */\n  onValueChange?: (value: string) => void\n  /** Called on Enter or the send button with the prompt, files, model and active toggles. */\n  onSubmit?: (payload: AiInputSubmit) => void\n  /** Called when the stop button is pressed while streaming. */\n  onStop?: () => void\n  /** Request state. \"streaming\" turns the send button into a stop button. Default: \"idle\" */\n  status?: AiInputStatus\n  /** Example prompts cycled through while the box is empty. Default: a few sample prompts */\n  placeholders?: string[]\n  /** Models offered in the selector. An empty array hides it. Default: [\"Auto\", \"Fast\", \"Reasoning\"] */\n  models?: string[]\n  /** Initially selected model. Default: the first model */\n  defaultModel?: string\n  /** Toggle chips shown in the toolbar, such as web search. Default: [] */\n  toggles?: AiInputToggle[]\n  /** Suggestion chips below the box that fill the input on click. Default: [] */\n  suggestions?: string[]\n  /** Rows the textarea grows to before scrolling. Default: 8 */\n  maxRows?: number\n  /** Accent for the send button, focus ring and active chips (any CSS color). Default: \"#ff4d12\" */\n  accent?: string\n  /** Corner radius of the box in px. Default: 20 */\n  radius?: number\n  /** Show the attach button. Default: true */\n  attachments?: boolean\n  /** Disable all input. Default: false */\n  disabled?: boolean\n  /** Additional classes for the root element. Default: undefined */\n  className?: string\n}\n\nconst DEFAULT_PLACEHOLDERS = [\"Ask anything…\", \"Summarize this article in three bullets\", \"Draft a launch tweet for a UI library\", \"Explain springs vs. easing curves\"]\nconst DEFAULT_MODELS = [\"Auto\", \"Fast\", \"Reasoning\"]\nconst LINE = 24\nconst ease = [0.22, 1, 0.36, 1] as const\nconst spring: Transition = { type: \"spring\", stiffness: 450, damping: 34 }\n\ntype Attached = { id: number; file: File }\n\nconst formatSize = (b: number) => (b < 1024 ? `${b} B` : b < 1024 * 1024 ? `${Math.round(b / 1024)} KB` : `${(b / 1024 / 1024).toFixed(1)} MB`)\n\n/** Rotating solid highlight segment that sweeps the 1px border; only mounted while visible. */\nfunction Ring({ rotate, speed, accent }: { rotate: MotionValue<number>; speed: number; accent: string }) {\n  useAnimationFrame((_, delta) => {\n    if (speed) rotate.set((rotate.get() + (delta / 1000) * speed) % 360)\n  })\n  return (\n    <motion.div\n      aria-hidden\n      className=\"absolute top-1/2 left-1/2 aspect-square w-[max(200%,40rem)] -translate-x-1/2 -translate-y-1/2\"\n      style={{ rotate }}\n    >\n      {/* A flat wedge from the centre outward; only the slice crossing the border shows. */}\n      <span className=\"absolute bottom-1/2 left-1/2 h-1/2 w-[18%]\" style={{ backgroundColor: accent }} />\n    </motion.div>\n  )\n}\n\nexport function AiInput({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  onSubmit,\n  onStop,\n  status = \"idle\",\n  placeholders = DEFAULT_PLACEHOLDERS,\n  models = DEFAULT_MODELS,\n  defaultModel,\n  toggles = [],\n  suggestions = [],\n  maxRows = 8,\n  accent = \"#ff4d12\",\n  radius = 20,\n  attachments = true,\n  disabled = false,\n  className,\n}: AiInputProps) {\n  const reduced = useReducedMotion()\n  const listId = useId()\n  const taRef = useRef<HTMLTextAreaElement>(null)\n  const fileRef = useRef<HTMLInputElement>(null)\n  const modelBtnRef = useRef<HTMLButtonElement>(null)\n  const listRef = useRef<HTMLUListElement>(null)\n  const popRef = useRef<HTMLDivElement>(null)\n  const nextId = useRef(0)\n  const rotate = useMotionValue(0)\n\n  const [inner, setInner] = useState(defaultValue)\n  const [files, setFiles] = useState<Attached[]>([])\n  const [model, setModel] = useState<string | undefined>(defaultModel ?? models[0])\n  const [on, setOn] = useState<string[]>([])\n  const [focused, setFocused] = useState(false)\n  const [phIndex, setPhIndex] = useState(0)\n  const [menu, setMenu] = useState(false)\n  const [hl, setHl] = useState(0)\n  const [dragging, setDragging] = useState(false)\n\n  const text = valueProp ?? inner\n  const empty = text.length === 0\n  const busy = status !== \"idle\"\n  const streaming = status === \"streaming\"\n  const canSend = !disabled && !busy && (text.trim().length > 0 || files.length > 0)\n  const activeModel = model !== undefined && models.includes(model) ? model : models[0]\n  const ph = placeholders.length ? placeholders[phIndex % placeholders.length] : \"\"\n  const ringOn = !disabled && (focused || streaming)\n  const t = (tr: Transition): Transition => (reduced ? { duration: 0 } : tr)\n\n  const setText = (v: string) => {\n    if (valueProp === undefined) setInner(v)\n    onValueChange?.(v)\n  }\n\n  // Auto-grow up to maxRows\n  useLayoutEffect(() => {\n    const ta = taRef.current\n    if (!ta) return\n    ta.style.height = \"auto\"\n    const max = Math.max(1, maxRows) * LINE\n    const content = ta.scrollHeight\n    ta.style.height = `${Math.min(content, max + 4)}px`\n    ta.style.overflowY = content > max + 4 ? \"auto\" : \"hidden\"\n  }, [text, maxRows])\n\n  // Cycle placeholders while empty\n  useEffect(() => {\n    if (!empty || placeholders.length < 2) return\n    const id = setInterval(() => setPhIndex((i) => (i + 1) % placeholders.length), 3200)\n    return () => clearInterval(id)\n  }, [empty, placeholders.length])\n\n  // Model menu: focus list, close on outside press\n  useEffect(() => {\n    if (!menu) return\n    listRef.current?.focus()\n    const onDown = (e: PointerEvent) => {\n      if (!popRef.current?.contains(e.target as Node)) setMenu(false)\n    }\n    document.addEventListener(\"pointerdown\", onDown)\n    return () => document.removeEventListener(\"pointerdown\", onDown)\n  }, [menu])\n\n  const addFiles = (list: FileList | null) => {\n    if (!list?.length) return\n    const added = Array.from(list).map((file) => ({ id: nextId.current++, file }))\n    setFiles((f) => [...f, ...added])\n  }\n\n  const submit = () => {\n    if (!canSend) return\n    onSubmit?.({ text: text.trim(), files: files.map((f) => f.file), model: activeModel, toggles: on })\n    setText(\"\")\n    setFiles([])\n  }\n\n  const openMenu = () => {\n    setHl(Math.max(0, models.indexOf(activeModel ?? \"\")))\n    setMenu(true)\n  }\n\n  const choose = (m: string) => {\n    setModel(m)\n    setMenu(false)\n    modelBtnRef.current?.focus()\n  }\n\n  const onListKey = (e: React.KeyboardEvent) => {\n    if (e.key === \"ArrowDown\") setHl((h) => (h + 1) % models.length)\n    else if (e.key === \"ArrowUp\") setHl((h) => (h - 1 + models.length) % models.length)\n    else if (e.key === \"Home\") setHl(0)\n    else if (e.key === \"End\") setHl(models.length - 1)\n    else if (e.key === \"Enter\" || e.key === \" \") choose(models[hl])\n    else if (e.key === \"Escape\") {\n      setMenu(false)\n      modelBtnRef.current?.focus()\n    } else if (e.key === \"Tab\") setMenu(false)\n    else return\n    e.preventDefault()\n  }\n\n  const chip =\n    \"inline-flex h-8 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium text-muted-foreground transition-colors outline-none hover:bg-foreground/[0.05] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\"\n\n  return (\n    <div className={cn(\"w-full\", className)}>\n      <div\n        className=\"relative overflow-hidden p-px transition-shadow duration-300\"\n        style={{\n          borderRadius: radius,\n          boxShadow: ringOn ? `0 0 0 4px color-mix(in oklab, ${accent} 12%, transparent)` : \"0 0 0 0 transparent\",\n        }}\n        onDragOver={(e) => {\n          if (!attachments || disabled) return\n          e.preventDefault()\n          setDragging(true)\n        }}\n        onDragLeave={() => setDragging(false)}\n        onDrop={(e) => {\n          if (!attachments || disabled) return\n          e.preventDefault()\n          setDragging(false)\n          addFiles(e.dataTransfer.files)\n        }}\n      >\n        <div aria-hidden className=\"absolute inset-0 bg-border\" />\n        <AnimatePresence>\n          {ringOn && (\n            <motion.div\n              key=\"ring\"\n              aria-hidden\n              className=\"absolute inset-0\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: streaming ? 1 : 0.7 }}\n              exit={{ opacity: 0 }}\n              transition={t({ duration: 0.4, ease })}\n            >\n              <Ring rotate={rotate} speed={reduced ? 0 : streaming ? 220 : 50} accent={accent} />\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        <div\n          className={cn(\"relative bg-card transition-colors\", dragging && \"bg-panel\")}\n          style={{ borderRadius: Math.max(0, radius - 1) }}\n          onClick={(e) => {\n            if (e.target === e.currentTarget) taRef.current?.focus()\n          }}\n        >\n          {/* Attached files */}\n          <AnimatePresence initial={false}>\n            {files.length > 0 && (\n              <motion.div\n                key=\"files\"\n                initial={{ height: 0, opacity: 0 }}\n                animate={{ height: \"auto\", opacity: 1 }}\n                exit={{ height: 0, opacity: 0 }}\n                transition={t({ duration: 0.3, ease })}\n                className=\"overflow-hidden\"\n              >\n                <ul className=\"flex flex-wrap gap-1.5 px-3 pt-3\" aria-label=\"Attached files\">\n                  <AnimatePresence mode=\"popLayout\" initial={false}>\n                    {files.map(({ id, file }) => {\n                      const Icon = file.type.startsWith(\"image/\") ? ImageIcon : FileText\n                      return (\n                        <motion.li\n                          key={id}\n                          layout\n                          initial={{ opacity: 0, scale: 0.9 }}\n                          animate={{ opacity: 1, scale: 1 }}\n                          exit={{ opacity: 0, scale: 0.9 }}\n                          transition={t(spring)}\n                          className=\"flex h-9 items-center gap-2 rounded-xl border bg-background py-1 pr-1 pl-2.5 text-xs\"\n                        >\n                          <Icon className=\"size-3.5 shrink-0 text-muted-foreground\" aria-hidden />\n                          <span className=\"max-w-[10rem] truncate font-medium text-foreground\">{file.name}</span>\n                          <span className=\"text-muted-foreground tabular-nums\">{formatSize(file.size)}</span>\n                          <button\n                            type=\"button\"\n                            aria-label={`Remove ${file.name}`}\n                            onClick={() => setFiles((f) => f.filter((x) => x.id !== id))}\n                            className=\"grid size-6 place-items-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n                          >\n                            <X className=\"size-3.5\" aria-hidden />\n                          </button>\n                        </motion.li>\n                      )\n                    })}\n                  </AnimatePresence>\n                </ul>\n              </motion.div>\n            )}\n          </AnimatePresence>\n\n          {/* Textarea + cycling placeholder */}\n          <div className=\"relative\">\n            <textarea\n              ref={taRef}\n              value={text}\n              rows={1}\n              disabled={disabled}\n              aria-label={ph || \"Message\"}\n              onChange={(e) => setText(e.target.value)}\n              onFocus={() => setFocused(true)}\n              onBlur={() => setFocused(false)}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\" && !e.shiftKey && !e.nativeEvent.isComposing) {\n                  e.preventDefault()\n                  submit()\n                }\n              }}\n              className=\"block w-full resize-none bg-transparent px-4 pt-3.5 pb-1 text-[15px] leading-6 text-foreground outline-none disabled:cursor-not-allowed\"\n            />\n            <AnimatePresence initial={false}>\n              {empty && ph && (\n                <motion.span\n                  key={ph}\n                  aria-hidden\n                  initial={reduced ? { opacity: 0 } : { opacity: 0, y: 8, filter: \"blur(4px)\" }}\n                  animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                  exit={reduced ? { opacity: 0 } : { opacity: 0, y: -8, filter: \"blur(4px)\" }}\n                  transition={{ duration: reduced ? 0.15 : 0.5, ease }}\n                  className=\"pointer-events-none absolute top-3.5 right-4 left-4 truncate text-[15px] leading-6 text-muted-foreground\"\n                >\n                  {ph}\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </div>\n\n          {/* Toolbar */}\n          <div className=\"flex items-center gap-1 px-2 pt-1 pb-2\">\n            {attachments && (\n              <>\n                <input\n                  ref={fileRef}\n                  type=\"file\"\n                  multiple\n                  tabIndex={-1}\n                  className=\"hidden\"\n                  onChange={(e) => {\n                    addFiles(e.target.files)\n                    e.target.value = \"\"\n                  }}\n                />\n                <button\n                  type=\"button\"\n                  aria-label=\"Attach files\"\n                  title=\"Attach files\"\n                  disabled={disabled}\n                  onClick={() => fileRef.current?.click()}\n                  className={cn(chip, \"w-8 justify-center px-0\")}\n                >\n                  <Paperclip className=\"size-4\" aria-hidden />\n                </button>\n              </>\n            )}\n\n            {models.length > 0 && (\n              <div ref={popRef} className=\"relative\">\n                <button\n                  ref={modelBtnRef}\n                  type=\"button\"\n                  disabled={disabled}\n                  aria-haspopup=\"listbox\"\n                  aria-expanded={menu}\n                  aria-controls={listId}\n                  aria-label={`Model: ${activeModel}`}\n                  onClick={() => (menu ? setMenu(false) : openMenu())}\n                  onKeyDown={(e) => {\n                    if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n                      e.preventDefault()\n                      openMenu()\n                    }\n                  }}\n                  className={chip}\n                >\n                  {activeModel}\n                  <motion.span animate={{ rotate: menu ? 180 : 0 }} transition={t(spring)} className=\"inline-grid\">\n                    <ChevronDown className=\"size-3.5\" aria-hidden />\n                  </motion.span>\n                </button>\n                <AnimatePresence>\n                  {menu && (\n                    <motion.ul\n                      ref={listRef}\n                      id={listId}\n                      role=\"listbox\"\n                      tabIndex={-1}\n                      aria-label=\"Model\"\n                      aria-activedescendant={`${listId}-${hl}`}\n                      onKeyDown={onListKey}\n                      initial={{ opacity: 0, scale: 0.96, y: 4 }}\n                      animate={{ opacity: 1, scale: 1, y: 0 }}\n                      exit={{ opacity: 0, scale: 0.96, y: 4 }}\n                      transition={t({ duration: 0.18, ease })}\n                      className=\"absolute bottom-full left-0 z-20 mb-2 min-w-40 origin-bottom-left rounded-xl border bg-popover p-1 text-sm text-popover-foreground shadow-lg outline-none\"\n                    >\n                      {models.map((m, i) => (\n                        <li\n                          key={m}\n                          id={`${listId}-${i}`}\n                          role=\"option\"\n                          aria-selected={m === activeModel}\n                          onPointerEnter={() => setHl(i)}\n                          onClick={() => choose(m)}\n                          className={cn(\n                            \"flex cursor-pointer items-center justify-between gap-3 rounded-lg px-2.5 py-1.5 transition-colors\",\n                            i === hl && \"bg-foreground/[0.06]\"\n                          )}\n                        >\n                          {m}\n                          {m === activeModel && <Check className=\"size-3.5\" style={{ color: accent }} aria-hidden />}\n                        </li>\n                      ))}\n                    </motion.ul>\n                  )}\n                </AnimatePresence>\n              </div>\n            )}\n\n            {toggles.map((tg) => {\n              const pressed = on.includes(tg.id)\n              return (\n                <motion.button\n                  key={tg.id}\n                  type=\"button\"\n                  disabled={disabled}\n                  aria-pressed={pressed}\n                  onClick={() => setOn((o) => (o.includes(tg.id) ? o.filter((x) => x !== tg.id) : [...o, tg.id]))}\n                  whileTap={reduced ? undefined : { scale: 0.95 }}\n                  transition={spring}\n                  className={cn(chip, pressed && \"hover:bg-transparent\")}\n                  style={\n                    pressed\n                      ? { color: accent, backgroundColor: `color-mix(in oklab, ${accent} 12%, transparent)` }\n                      : undefined\n                  }\n                >\n                  {tg.icon && <span className=\"inline-grid [&_svg]:size-3.5\">{tg.icon}</span>}\n                  {tg.label}\n                </motion.button>\n              )\n            })}\n\n            <div className=\"ml-auto\" />\n\n            <motion.button\n              type=\"button\"\n              aria-label={streaming ? \"Stop generating\" : status === \"submitting\" ? \"Sending\" : \"Send message\"}\n              disabled={streaming ? false : !canSend}\n              onClick={() => (streaming ? onStop?.() : submit())}\n              whileTap={reduced ? undefined : { scale: 0.9 }}\n              animate={{ borderRadius: streaming ? 12 : 18 }}\n              style={{ backgroundColor: canSend || busy ? accent : \"color-mix(in oklab, currentColor 10%, transparent)\" }}\n              transition={t(spring)}\n              className={cn(\n                \"relative grid size-9 place-items-center overflow-hidden transition-[background-color,color] duration-300 outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed\",\n                canSend || busy ? \"text-white\" : \"text-muted-foreground\"\n              )}\n            >\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                <motion.span\n                  key={status}\n                  initial={{ opacity: 0, scale: 0.5, rotate: -45 }}\n                  animate={{ opacity: 1, scale: 1, rotate: 0 }}\n                  exit={{ opacity: 0, scale: 0.5, rotate: 45 }}\n                  transition={t(spring)}\n                  className=\"grid place-items-center\"\n                >\n                  {streaming ? (\n                    <Square className=\"size-3.5\" fill=\"currentColor\" strokeWidth={0} aria-hidden />\n                  ) : status === \"submitting\" ? (\n                    <motion.span\n                      className=\"block size-4 rounded-full border-2 border-white/30 border-t-white\"\n                      animate={{ rotate: 360 }}\n                      transition={{ duration: 0.8, repeat: Infinity, ease: \"linear\" }}\n                    />\n                  ) : (\n                    <ArrowUp className=\"size-4\" strokeWidth={2.4} aria-hidden />\n                  )}\n                </motion.span>\n              </AnimatePresence>\n            </motion.button>\n          </div>\n        </div>\n      </div>\n\n      {/* Suggestions */}\n      {suggestions.length > 0 && (\n        <AnimatePresence initial={false}>\n          {empty && !busy && (\n            <motion.div\n              key=\"suggestions\"\n              initial={{ opacity: 0, y: -4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: -4 }}\n              transition={t({ duration: 0.3, ease })}\n              className=\"mt-3 flex flex-wrap justify-center gap-2\"\n            >\n              {suggestions.map((s, i) => (\n                <motion.button\n                  key={s}\n                  type=\"button\"\n                  disabled={disabled}\n                  onClick={() => {\n                    setText(s)\n                    taRef.current?.focus()\n                  }}\n                  initial={reduced ? false : { opacity: 0, y: 6 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  transition={{ duration: 0.4, ease, delay: reduced ? 0 : i * 0.04 }}\n                  whileTap={reduced ? undefined : { scale: 0.97 }}\n                  className=\"rounded-full border bg-card px-3 py-1.5 text-xs text-muted-foreground transition-colors outline-none hover:bg-foreground/[0.05] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50\"\n                >\n                  {s}\n                </motion.button>\n              ))}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/ai-input",
  "type": "registry:component"
}