{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "composer",
  "title": "Composer",
  "description": "A chat composer that grows on a spring, opens an attachments row with Auto Height, and morphs its Send arrow into a Stop square while a reply streams. Enter sends, Shift+Enter breaks the line, Escape stops; files can be picked, dropped or pasted. Wires straight into AI SDK useChat.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-chat-core.json",
    "https://eased.dev/r/eased-field.json",
    "https://eased.dev/r/eased-auto-height.json",
    "https://eased.dev/r/eased-scroll-fade.json",
    "https://eased.dev/r/attachment.json",
    "https://eased.dev/r/button.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/composer/composer.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport {\n  useCallback,\n  useImperativeHandle,\n  useRef,\n  useState,\n  type ClipboardEvent,\n  type DragEvent,\n  type KeyboardEvent,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport {\n  isBusy,\n  toSubmission,\n  useAttachments,\n  useAutosize,\n  useComposerKeys,\n  type Attachment,\n  type ChatStatus,\n  type ComposerSubmission,\n  type ComposerTrigger,\n  type UploadFn,\n} from \"../_primitives/chat-core/chat-core\";\nimport { AutoHeight } from \"../_primitives/auto-height/auto-height\";\nimport { InputGroup, controlClass } from \"../_primitives/field/field\";\nimport { scrollFadeMask, useScrollEdges } from \"../_primitives/scroll-fade/scroll-fade\";\nimport { AttachmentList } from \"../attachment/attachment-chip\";\nimport { Button } from \"../button/button\";\nimport { bouncy, enter, exit, instant, reducedFade, useSafeReducedMotion } from \"../lib/motion\";\nimport { pressIcon } from \"../lib/press\";\n\nexport type ComposerHandle = {\n  focus: () => void;\n  /** The `<textarea>`, e.g. to anchor a Slash or Mention menu at the caret. */\n  textarea: HTMLTextAreaElement | null;\n  /** Opens the file picker. */\n  openFilePicker: () => void;\n};\n\nexport type ComposerProps = {\n  /** Controlled text. Pair with `onValueChange`. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /**\n   * Called on Enter or Send with the text, the attachments and the finished ones as AI SDK\n   * `file` parts. Uncontrolled text and attachments clear themselves afterwards.\n   */\n  onSubmit?: (submission: ComposerSubmission) => void;\n  /** `useChat().status`. `submitted` and `streaming` morph Send into Stop. Default `ready`. */\n  status?: ChatStatus;\n  /** Stop the reply: the Stop button and Escape while busy. */\n  onStop?: () => void;\n\n  /** Controlled attachments. Leave out to let the composer keep and upload its own. */\n  attachments?: Attachment[];\n  /** Files picked, dropped or pasted, when `attachments` is controlled. */\n  onAttach?: (files: File[]) => void;\n  onRemoveAttachment?: (id: string) => void;\n  onRetryAttachment?: (id: string) => void;\n  /** Uncontrolled attachments only: uploads each file with progress. Without it files stay local. */\n  upload?: UploadFn;\n  /** `<input accept>` syntax. Default: anything. */\n  accept?: string;\n  /** Uncontrolled only: bytes per file; bigger files show an error chip. */\n  maxSize?: number;\n  /** Uncontrolled only. */\n  maxFiles?: number;\n  /** Hide the attach button and ignore drops and pasted files. Default true (attachments on). */\n  allowAttachments?: boolean;\n\n  placeholder?: string;\n  /** Default 1. */\n  minRows?: number;\n  /** Grow to this many lines, then scroll inside with soft edges. Default 6. */\n  maxRows?: number;\n  disabled?: boolean;\n  autoFocus?: boolean;\n  /** Accessible name of the textarea. Default \"Message\". */\n  \"aria-label\"?: string;\n  /** `enter` (default) or `mod-enter`. */\n  submitOn?: \"enter\" | \"mod-enter\";\n  /** ↑ in an empty composer, or ⌘↑ anywhere. */\n  onEditLast?: () => void;\n\n  /* ---------- Pro hooks: slash commands, @mentions, model picker ---------- */\n\n  /** The `/` or `@` trigger under the caret after every keystroke (or null). Open your menu from here. */\n  onTrigger?: (trigger: ComposerTrigger | null) => void;\n  /** Trigger characters. Default `[\"/\", \"@\"]`. */\n  triggerChars?: readonly string[];\n  /** Runs first on every keydown; return true to claim the key (an open menu's ↑ ↓ Enter Esc). */\n  interceptKey?: (event: KeyboardEvent<HTMLTextAreaElement>) => boolean;\n  /** Inside the shell, above the attachments: e.g. a quoted reply or a capability card. */\n  header?: ReactNode;\n  /** Bottom row, after the attach button: tools, a \"+\" menu. */\n  toolbar?: ReactNode;\n  /** Bottom row, just before Send: a model picker, a voice button, a context meter. */\n  actions?: ReactNode;\n\n  ref?: Ref<ComposerHandle>;\n  className?: string;\n};\n\n/** Arrow and stop as the same seven-point outline, so one morphs into the other point for point. */\nconst ARROW = \"M8 3L12.5 7.5L9.25 7.5L9.25 13L6.75 13L6.75 7.5L3.5 7.5Z\";\nconst STOP = \"M8 4.5L11.5 4.5L11.5 8L11.5 11.5L4.5 11.5L4.5 8L4.5 4.5Z\";\n\n/**\n * A chat composer: a textarea that grows on a spring (then scrolls inside with soft edges),\n * an attachments row that opens with Auto Height, and one button whose arrow morphs into a\n * stop square while a reply streams, without changing size. Enter sends, Shift+Enter breaks\n * the line, Escape stops. Files can be picked, dropped onto it or pasted.\n */\nexport function Composer({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  onSubmit,\n  status = \"ready\",\n  onStop,\n  attachments: attachmentsProp,\n  onAttach,\n  onRemoveAttachment,\n  onRetryAttachment,\n  upload,\n  accept,\n  maxSize,\n  maxFiles,\n  allowAttachments = true,\n  placeholder = \"Ask anything…\",\n  minRows = 1,\n  maxRows = 6,\n  disabled = false,\n  autoFocus,\n  \"aria-label\": ariaLabel = \"Message\",\n  submitOn = \"enter\",\n  onEditLast,\n  onTrigger,\n  triggerChars,\n  interceptKey,\n  header,\n  toolbar,\n  actions,\n  ref,\n  className = \"\",\n}: ComposerProps) {\n  const reduced = useSafeReducedMotion();\n  const textarea = useRef<HTMLTextAreaElement>(null);\n  const fileInput = useRef<HTMLInputElement>(null);\n\n  const [innerValue, setInnerValue] = useState(defaultValue);\n  const value = valueProp ?? innerValue;\n  const setValue = (v: string) => {\n    if (valueProp === undefined) setInnerValue(v);\n    onValueChange?.(v);\n  };\n\n  const own = useAttachments({ upload, accept, maxSize, maxFiles });\n  const controlled = attachmentsProp !== undefined;\n  const attachments = attachmentsProp ?? own.attachments;\n  const addFiles = (files: File[]) => {\n    if (!files.length || !allowAttachments) return;\n    if (controlled) onAttach?.(files);\n    else own.add(files);\n  };\n  const removeAttachment = (id: string) => {\n    if (controlled) onRemoveAttachment?.(id);\n    else own.remove(id);\n    textarea.current?.focus();\n  };\n  const retryAttachment = controlled ? onRetryAttachment : own.retry;\n\n  const busy = isBusy(status);\n  const uploading = attachments.some((a) => a.status === \"uploading\");\n  const sendable = attachments.some((a) => (a.status ?? \"done\") === \"done\");\n  const canSend = !disabled && !busy && !uploading && (value.trim().length > 0 || sendable);\n\n  const submit = useCallback(() => {\n    if (!canSend) return;\n    onSubmit?.(toSubmission(value.trim(), attachments.filter((a) => (a.status ?? \"done\") === \"done\")));\n    if (valueProp === undefined) setInnerValue(\"\");\n    onValueChange?.(\"\");\n    if (!controlled) own.clear();\n  }, [canSend, onSubmit, value, attachments, valueProp, onValueChange, controlled, own]);\n\n  const stop = () => {\n    onStop?.();\n    textarea.current?.focus();\n  };\n\n  const keys = useComposerKeys({\n    onSubmit: submit,\n    canSubmit: canSend,\n    submitOn,\n    onEditLast,\n    onEscape: () => {\n      if (!busy || !onStop) return false;\n      onStop();\n      return true;\n    },\n    intercept: interceptKey,\n    triggerChars,\n    onTrigger,\n  });\n\n  const { height } = useAutosize(textarea, { minRows, maxRows, value });\n  useScrollEdges(textarea);\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      focus: () => textarea.current?.focus(),\n      get textarea() {\n        return textarea.current;\n      },\n      openFilePicker: () => fileInput.current?.click(),\n    }),\n    [],\n  );\n\n  // Drag and drop onto the composer. A counter, because dragenter/leave fire for every child.\n  const [dragging, setDragging] = useState(false);\n  const depth = useRef(0);\n  const hasFiles = (e: DragEvent) => allowAttachments && !disabled && Array.from(e.dataTransfer.types).includes(\"Files\");\n  const dropHandlers = {\n    onDragEnter: (e: DragEvent) => {\n      if (!hasFiles(e)) return;\n      e.preventDefault();\n      depth.current++;\n      setDragging(true);\n    },\n    onDragOver: (e: DragEvent) => {\n      if (!hasFiles(e)) return;\n      e.preventDefault();\n      e.dataTransfer.dropEffect = \"copy\";\n    },\n    onDragLeave: (e: DragEvent) => {\n      if (!hasFiles(e)) return;\n      depth.current = Math.max(0, depth.current - 1);\n      if (depth.current === 0) setDragging(false);\n    },\n    onDrop: (e: DragEvent) => {\n      if (!hasFiles(e)) return;\n      e.preventDefault();\n      depth.current = 0;\n      setDragging(false);\n      addFiles(Array.from(e.dataTransfer.files));\n      textarea.current?.focus();\n    },\n  };\n\n  const onPaste = (e: ClipboardEvent<HTMLTextAreaElement>) => {\n    const files = Array.from(e.clipboardData.files);\n    if (!files.length || !allowAttachments) return;\n    // A screenshot pastes as a file; text that also carries a file (some apps) keeps the text.\n    if (!e.clipboardData.getData(\"text/plain\")) e.preventDefault();\n    addFiles(files);\n  };\n\n  const sendLabel = busy ? \"Stop generating\" : uploading ? \"Waiting for uploads to finish\" : \"Send message\";\n  const sendActive = busy ? Boolean(onStop) : canSend;\n\n  return (\n    <form\n      className={`relative w-full min-w-0 ${className}`}\n      onSubmit={(e) => {\n        e.preventDefault();\n        submit();\n      }}\n    >\n      <InputGroup\n        multiline\n        size=\"lg\"\n        {...dropHandlers}\n        aria-disabled={disabled || undefined}\n        className=\"gap-0! rounded-panel! p-2 [--pad:8px]! [--radius-outer:var(--eased-radius-panel)]!\"\n      >\n        {header}\n        {allowAttachments && (\n          <AutoHeight open={attachments.length > 0}>\n            {/* Chips share the text line (18px in), with room above for their remove buttons. */}\n            <AttachmentList attachments={attachments} onRemove={removeAttachment} onRetry={retryAttachment} className=\"px-2.5 pt-2.5 pb-1\" />\n          </AutoHeight>\n        )}\n\n        <motion.div style={{ height }} className=\"relative [overflow:clip]\">\n          <textarea\n            ref={textarea}\n            rows={minRows}\n            value={value}\n            placeholder={placeholder}\n            aria-label={ariaLabel}\n            disabled={disabled}\n            autoFocus={autoFocus}\n            enterKeyHint=\"send\"\n            onChange={(e) => setValue(e.target.value)}\n            onKeyDown={keys.onKeyDown}\n            onSelect={keys.onSelect}\n            onPaste={onPaste}\n            style={scrollFadeMask(\"y\", 16)}\n            className={`${controlClass} block w-full resize-none px-2.5 py-1.5 text-ui [scrollbar-width:thin]`}\n          />\n        </motion.div>\n\n        {/* Bottom row: the plus glyph's edge sits on the text line (rule 104); Send is flush with the 8px pad (rule 105). */}\n        <div className=\"flex min-w-0 items-center gap-1 pt-1\">\n          {allowAttachments && (\n            <>\n              <Button\n                variant=\"ghost\"\n                size=\"icon-sm\"\n                hitArea={false}\n                aria-label=\"Add files\"\n                disabled={disabled}\n                onClick={() => fileInput.current?.click()}\n                className=\"\"\n              >\n                <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" aria-hidden>\n                  <path d=\"M8 2.75v10.5M2.75 8h10.5\" />\n                </svg>\n              </Button>\n              <input\n                ref={fileInput}\n                type=\"file\"\n                multiple\n                accept={accept}\n                tabIndex={-1}\n                className=\"sr-only\"\n                aria-hidden\n                onChange={(e) => {\n                  addFiles(Array.from(e.target.files ?? []));\n                  e.target.value = \"\";\n                  textarea.current?.focus();\n                }}\n              />\n            </>\n          )}\n          {toolbar}\n          <div className=\"min-w-0 flex-1\" />\n          {actions}\n          <button\n            type={busy ? \"button\" : \"submit\"}\n            aria-label={sendLabel}\n            aria-disabled={!sendActive || undefined}\n            onClick={(e) => {\n              if (!sendActive) e.preventDefault();\n              else if (busy) stop();\n            }}\n            className={`relative inline-flex size-8 shrink-0 items-center justify-center bg-brand text-text-on-brand inset-shadow-highlight rounded-control-sm ${pressIcon} ${\n              sendActive ? \"cursor-pointer hover-fine:bg-brand-hover\" : \"cursor-not-allowed opacity-40 active:scale-100\"\n            }`}\n          >\n            <svg viewBox=\"0 0 16 16\" className=\"size-4\" aria-hidden>\n              <motion.path\n                initial={false}\n                animate={{ d: busy ? STOP : ARROW }}\n                transition={reduced ? instant : bouncy}\n                fill=\"currentColor\"\n                stroke=\"currentColor\"\n                strokeWidth={1.25}\n                strokeLinejoin=\"round\"\n              />\n            </svg>\n          </button>\n        </div>\n\n        <AnimatePresence>\n          {dragging && (\n            <motion.div\n              key=\"drop\"\n              aria-hidden\n              className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-[inherit] border border-dashed border-brand-line bg-brand-subtle text-ui font-medium text-brand-text\"\n              initial={{ opacity: 0, scale: reduced ? 1 : 0.985 }}\n              animate={{ opacity: 1, scale: 1, transition: reduced ? reducedFade : enter }}\n              exit={{ opacity: 0, transition: reduced ? reducedFade : exit }}\n            >\n              Drop files to attach\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </InputGroup>\n      <span role=\"status\" className=\"sr-only\">\n        {busy ? \"Generating a reply\" : \"\"}\n      </span>\n    </form>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/composer/composer.tsx"
    }
  ],
  "docs": "Import Composer from components/eased/composer/composer. With AI SDK: const { composer } = fromUseChat(useChat()); <Composer {...composer} />.",
  "type": "registry:ui"
}