Chat composer

Free · MIT

Prompt input with an auto-growing textarea, attachment chips from picker, paste or drop, a slash-command menu, model and mode selectors, a token ring against the context limit, Enter or Cmd+Enter to send and a Stop button while streaming.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/chat-composer.json

The file lands in components/goodui/chat-composer.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/chat-composer works too.

Usage

import { ChatComposer } from "@/components/goodui/chat-composer"

<ChatComposer
  placeholder="Ask Aiko…"
  accept="image/*,.pdf"
  commands={[{ name: "summarize", description: "Summarise this chat" }, { name: "schedule", description: "Find a slot" }]}
  models={MODELS}
  modes={[{ id: "chat", label: "Chat" }, { id: "agent", label: "Agent" }]}
  maxTokens={8000}
  streaming={isStreaming}
  onStop={abort}
  onSubmit={(text, { attachments, model, mode }) => send(text, attachments, model, mode)}
/>

When to use

Use it for
  • The input of any assistant chat: Enter sends, Shift+Enter adds a line, the button turns into Stop while the answer streams.
  • Prompts that carry files (paste, drop or pick) and need a model or mode choice right there.
  • Products with slash commands such as /summarize or /schedule.
Reach for something else
  • Plain forms and comments: use input or a textarea.
  • A command palette with many actions: use command-palette-morph; the slash menu is for a handful of commands.
If you needUse
Only Cmd/Ctrl+Enter should sendsubmitOnEnter={false}
File attachmentsaccept="image/*,.pdf" (the attach button and paste/drop appear)
Model picker in the footermodels (ModelOption[]) with model/onModelChange
Chat / Research / Agent switchmodes with mode/onModeChange
Context limitmaxTokens (shows the ring and blocks sending above it)
Your tokenizercountTokens={(text) => …}

Examples

Controlled value filled by suggestion chips

const [draft, setDraft] = React.useState("")
<SuggestionChips suggestions={ideas} onSelect={setDraft} />
<ChatComposer value={draft} onValueChange={setDraft} onSubmit={send} />

Commands that run instead of inserting

<ChatComposer commands={COMMANDS} onCommand={(c) => run(c.name)} />

With a send sound

<ChatComposer onSubmit={send} sound />

API reference

PropTypeDefault
autoFocusboolean—
onKeyDown
Key handler on the textarea; call preventDefault to override the Enter behavior.
(e: React.KeyboardEvent<HTMLTextAreaElement>) => void—
onPaste
Paste handler on the textarea; pasted files become attachments when `accept` is set.
(e: React.ClipboardEvent<HTMLTextAreaElement>) => void—
valuestring—
defaultValuestring""
onValueChange(value: string) => void—
onSubmit
Called with the trimmed text and the attachments, model and mode in effect. Clears the field when uncontrolled.
(text: string, meta: { attachments: ComposerAttachment[]; model?: string; mode?: string }) => void—
streaming
The answer is streaming: the send button becomes Stop and calls onStop.
booleanfalse
onStop() => void—
attachmentsComposerAttachment[]—
defaultAttachmentsComposerAttachment[][]
onAttachmentsChange(attachments: ComposerAttachment[]) => void—
accept
Accept attribute for the file picker, e.g. "image/*,.pdf". Omit to hide the attach button.
string—
maxAttachmentsnumber10
commands
Typing "/" at the start opens this list.
SlashCommand[]—
onCommand
Runs when a command is picked; by default the command is inserted as "/name ".
(command: SlashCommand) => void—
models
Models for a built-in ModelPicker in the footer.
ModelOption[]—
modelstring—
defaultModelstring—
onModelChange(id: string) => void—
modes
Modes shown as a segmented control, e.g. Chat / Research / Code.
ComposerMode[]—
modestring—
defaultModestring—
onModeChange(id: string) => void—
maxTokens
Context limit; shows the counter and blocks sending above it.
number—
countTokens
Your tokenizer; defaults to about one token per four characters.
(text: string) => number—
placeholder
Textarea placeholder; also its accessible name.
string—
minRowsnumber1
maxRowsnumber8
submitOnEnter
Enter sends (Shift+Enter adds a line). false: only Cmd/Ctrl+Enter sends.
booleantrue
disabledboolean—
footer
Extra controls at the start of the footer.
React.ReactNode—
labels
Text for built-in controls and screen readers; pass translations here.
Partial<ChatComposerLabels>—
sound
Plays "send" on submit when the visitor has sounds on (lib/goodui-sounds); a sound name picks another. Off by default.
boolean | SoundName—

Also accepts every <form> attribute.

Accessibility

EnterSends (unless submitOnEnter is false or an IME composition is in progress). Shift+Enter inserts a new line.
Cmd/Ctrl+EnterAlways sends.
/ at the startOpens the command menu, filtered by what follows.
ArrowUp / ArrowDownMoves through the command menu.
Enter / TabPicks the active command; Escape closes the menu.
  • The textarea has an aria-label (the placeholder) and is described by a hidden hint explaining Enter and Shift+Enter.
  • The command menu is a listbox with option rows; the modes are a radiogroup; the send button's label switches between Send and Stop.
  • The token counter is read as "1.2K of 8K tokens" or "Over the token limit"; attachment remove buttons are labelled with the file name.
  • Built-in strings go through the labels prop for translation.

Motion

  • The textarea grows with its content between minRows and maxRows (height set from scrollHeight, no transition).
  • The command menu springs up from the bottom edge (stiffness 520, damping 34) with rows staggered 30 ms; the active row highlight glides on a shared-layout spring.
  • Attachment chips spring in at 0.8 scale and the row's height animates to fit; the send arrow and stop square swap with a rotate-and-scale spring; the token ring eases over 0.35 s.
  • Reduced motion: menus and chips fade, the icon swap is a fade, the ring jumps.

Notes for AI

Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.

  • onSubmit gets the trimmed text and { attachments, model, mode }. When uncontrolled, the field and attachments clear after submit.
  • While streaming is true the button calls onStop instead of submitting; sending is also blocked when the text is empty (and no attachments) or over maxTokens.
  • The default token estimate is characters / 4; pass countTokens for your tokenizer.
  • Attachments created from files get an object URL for image previews; revoke them yourself if you keep attachments around for long.
  • The built-in model picker is model-picker (installed alongside); pass models to show it.

FAQ

How do I add my own buttons to the footer?

Pass them as `footer`; they render after the attach button.

Can I keep sending on Enter but insert newlines on mobile?

Set submitOnEnter={false} on touch devices; the send button and Cmd/Ctrl+Enter still work.

Related