{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"prompt-composer","type":"registry:component","title":"Prompt composer","description":"The input that starts it all: grows as you type, takes files, and turns Send into Stop while answering.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json"],"files":[{"path":"registry/prompt-composer.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowUp, Paperclip, Square } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\n\n/* ─────────────────────────────────────────────────────────\n * PROMPT COMPOSER: where the conversation starts\n *\n *   ready      type; Enter sends, Shift+Enter adds a line\n *   submitted  sent: the arrow blurs into a spinner\n *   streaming  the spinner blurs into Stop\n *   error      ready to try again\n *   disabled   with a reason, e.g. a rate limit, folding open\n *              above the field\n *\n * Near the limit a counter opens in and rolls as you type;\n * attachments fold open above the field as the first one lands.\n *\n * status takes useChat's status as is. Files come in through the\n * paperclip, paste or drag and drop; render them as chips in the\n * attachments slot. The toolbar slot fits a mode switcher.\n * ───────────────────────────────────────────────────────── */\n\nexport type ComposerStatus = \"ready\" | \"submitted\" | \"streaming\" | \"error\";\n\nexport interface PromptComposerProps extends Omit<React.FormHTMLAttributes<HTMLFormElement>, \"onSubmit\" | \"defaultValue\"> {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  onSubmit: (value: string) => void;\n  onStop?: () => void;\n  /** Pass useChat's status straight through. */\n  status?: ComposerStatus;\n  placeholder?: string;\n  disabled?: boolean;\n  /** Shown above the input while disabled, e.g. \"You've reached today's limit.\" */\n  disabledReason?: React.ReactNode;\n  /** Enables the paperclip, paste and drop. */\n  onFilesSelected?: (files: File[]) => void;\n  accept?: string;\n  /** Attachment chips, shown above the text. */\n  attachments?: React.ReactNode;\n  /** Controls at the bottom left, e.g. a mode switcher. */\n  toolbar?: React.ReactNode;\n  maxLength?: number;\n  /** Allow sending with no text, e.g. when files are attached. */\n  allowEmpty?: boolean;\n  autoFocus?: boolean;\n}\n\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,\n});\n\n/** A block that folds open to its height and shut again, keeping what it showed while it closes. */\nfunction Fold({ show, reduced, children }: { show: boolean; reduced: boolean; children: React.ReactNode }) {\n  const [last, setLast] = React.useState(children);\n  if (show && children !== last) setLast(children);\n  return (\n    <div\n      aria-hidden={!show || undefined}\n      inert={!show}\n      className=\"grid\"\n      style={{\n        gridTemplateRows: show ? \"1fr\" : \"0fr\",\n        opacity: show ? 1 : 0,\n        transition: reduced ? \"none\" : `grid-template-rows 380ms ${MORPH}, opacity ${show ? \"280ms\" : \"140ms\"} ${MORPH}`,\n      }}\n    >\n      <div className=\"min-h-0 overflow-hidden\">{show ? children : last}</div>\n    </div>\n  );\n}\n\nexport function PromptComposer({\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  onSubmit,\n  onStop,\n  status = \"ready\",\n  placeholder = \"Ask anything\",\n  disabled = false,\n  disabledReason,\n  onFilesSelected,\n  accept,\n  attachments,\n  toolbar,\n  maxLength,\n  allowEmpty = false,\n  autoFocus,\n  className = \"\",\n  ...props\n}: PromptComposerProps) {\n  const reduced = useReducedMotion();\n  const [own, setOwn] = React.useState(defaultValue);\n  const [dragging, setDragging] = React.useState(false);\n  const text = value ?? own;\n  const textareaRef = React.useRef<HTMLTextAreaElement>(null);\n  const fileRef = React.useRef<HTMLInputElement>(null);\n  const busy = status === \"submitted\" || status === \"streaming\";\n  const canSend = !disabled && !busy && (allowEmpty || text.trim().length > 0) && (maxLength === undefined || text.length <= maxLength);\n\n  const setText = (next: string) => {\n    if (value === undefined) setOwn(next);\n    onValueChange?.(next);\n  };\n\n  // Grow with the text, up to a scrolling maximum.\n  React.useLayoutEffect(() => {\n    const el = textareaRef.current;\n    if (!el) return;\n    el.style.height = \"auto\";\n    el.style.height = `${Math.min(el.scrollHeight, 200)}px`;\n  }, [text]);\n\n  const submit = () => {\n    if (!canSend) return;\n    onSubmit(text);\n    if (value === undefined) setOwn(\"\");\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n    if (e.key === \"Enter\" && !e.shiftKey && !e.nativeEvent.isComposing) {\n      e.preventDefault();\n      submit();\n    }\n  };\n\n  const takeFiles = (list: FileList | null) => {\n    if (!list || list.length === 0 || !onFilesSelected) return false;\n    onFilesSelected(Array.from(list));\n    return true;\n  };\n\n  const action = busy ? (status === \"streaming\" && onStop ? \"stop\" : \"wait\") : \"send\";\n\n  return (\n    <form\n      onSubmit={(e) => {\n        e.preventDefault();\n        submit();\n      }}\n      onDragOver={(e) => {\n        if (!onFilesSelected || !e.dataTransfer.types.includes(\"Files\")) return;\n        e.preventDefault();\n        setDragging(true);\n      }}\n      onDragLeave={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragging(false);\n      }}\n      onDrop={(e) => {\n        if (!onFilesSelected) return;\n        e.preventDefault();\n        setDragging(false);\n        takeFiles(e.dataTransfer.files);\n      }}\n      className={`relative rounded-2xl border bg-background transition-colors focus-within:border-foreground/25 ${\n        dragging ? \"border-dashed border-foreground/40\" : \"border-border\"\n      } ${className}`}\n      {...props}\n    >\n      <Fold show={Boolean(disabled && disabledReason)} reduced={reduced}>\n        <div className=\"border-b border-border px-3.5 py-2 text-[12px] text-muted-foreground\">{disabledReason}</div>\n      </Fold>\n\n      <Fold show={Boolean(attachments)} reduced={reduced}>\n        <div className=\"flex flex-wrap gap-2 px-3 pt-3\">{attachments}</div>\n      </Fold>\n\n      <textarea\n        ref={textareaRef}\n        rows={1}\n        value={text}\n        autoFocus={autoFocus}\n        disabled={disabled}\n        placeholder={placeholder}\n        aria-label={placeholder}\n        onChange={(e) => setText(e.target.value)}\n        onKeyDown={onKeyDown}\n        onPaste={(e) => {\n          if (takeFiles(e.clipboardData.files) && !e.clipboardData.getData(\"text\")) e.preventDefault();\n        }}\n        className=\"block max-h-[200px] w-full resize-none overflow-y-auto bg-transparent px-3.5 pb-1 pt-3 text-[14px] leading-6 text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60\"\n      />\n\n      <div className=\"flex items-center gap-1.5 px-2 pb-2 pt-1\">\n        {onFilesSelected && (\n          <>\n            <button\n              type=\"button\"\n              aria-label=\"Attach files\"\n              disabled={disabled}\n              onClick={() => fileRef.current?.click()}\n              className={`flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40 ${FOCUS}`}\n            >\n              <Paperclip className=\"size-4\" />\n            </button>\n            <input\n              ref={fileRef}\n              type=\"file\"\n              multiple\n              accept={accept}\n              className=\"hidden\"\n              onChange={(e) => {\n                takeFiles(e.target.files);\n                e.target.value = \"\";\n              }}\n            />\n          </>\n        )}\n        {toolbar}\n\n        <div className=\"ml-auto flex items-center gap-2.5\">\n          {maxLength !== undefined && (\n            // Opens in near the limit and rolls as you type; red once you're over.\n            <span\n              aria-hidden={text.length <= maxLength * 0.8 || undefined}\n              className=\"grid\"\n              style={{\n                gridTemplateColumns: text.length > maxLength * 0.8 ? \"1fr\" : \"0fr\",\n                opacity: text.length > maxLength * 0.8 ? 1 : 0,\n                transition: reduced ? \"none\" : `grid-template-columns 380ms ${MORPH}, opacity 260ms ${MORPH}`,\n              }}\n            >\n              <span\n                className={`flex min-w-0 items-baseline overflow-hidden whitespace-nowrap font-mono text-[11px] tabular-nums transition-colors duration-300 ${\n                  text.length > maxLength ? \"text-red-500\" : \"text-muted-foreground\"\n                }`}\n              >\n                <NumberRoll value={text.length} duration={300} />/{maxLength}\n              </span>\n            </span>\n          )}\n          <button\n            type={action === \"send\" ? \"submit\" : \"button\"}\n            aria-label={action === \"stop\" ? \"Stop\" : action === \"wait\" ? \"Sending\" : \"Send\"}\n            disabled={action === \"send\" ? !canSend : action === \"wait\"}\n            onClick={action === \"stop\" ? onStop : undefined}\n            className={`relative flex size-8 items-center justify-center rounded-full bg-primary text-primary-foreground transition-[opacity,transform] duration-200 active:scale-95 disabled:opacity-35 ${FOCUS}`}\n          >\n            <span aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center\" style={swap(action === \"send\", reduced)}>\n              <ArrowUp className=\"size-4\" />\n            </span>\n            <span aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center\" style={swap(action === \"stop\", reduced)}>\n              <Square fill=\"currentColor\" className=\"size-3\" />\n            </span>\n            <span aria-hidden=\"true\" className=\"absolute inset-0 flex items-center justify-center\" style={swap(action === \"wait\", reduced)}>\n              {/* Spins only while it's showing. */}\n              <span className={`size-3.5 rounded-full border-[1.5px] border-current/35 border-t-current motion-reduce:animate-none ${action === \"wait\" ? \"animate-spin\" : \"\"}`} />\n            </span>\n          </button>\n        </div>\n      </div>\n\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-background/85 text-[13px] font-medium text-foreground\"\n        style={{ opacity: dragging ? 1 : 0, transition: reduced ? \"none\" : `opacity 200ms ${MORPH}` }}\n      >\n        Drop files to attach\n      </div>\n    </form>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/prompt-composer"}