Prompt Input
The chat composer — a real form that starts on one line and reflows to multiline as the prompt grows, with a send/stop control and optional attachment chips.
Nothing submitted yet.
<PromptInput status={status} onSubmit={handleSubmit} onStop={handleStop}> <PromptInputTextarea value={draft} onChange={(event) => setDraft(event.target.value)} placeholder="Ask about your client book…" /> <PromptInputToolbar> <PromptInputTools align="end"> <PromptInputSubmit /> </PromptInputTools> </PromptInputToolbar></PromptInput>Usage
import {
PromptInput,
PromptInputAttachment,
PromptInputAttachments,
PromptInputSubmit,
PromptInputTextarea,
PromptInputToolbar,
PromptInputTools,
} from "@reva/ui";PromptInput is a real <form>, so Enter, requestSubmit(), and submit buttons all flow through native form semantics. On submit it reads the composed textarea, trims the value, ignores empty input, and calls onSubmit(text).
It does not clear the textarea — the consumer owns input state and clears it when the send succeeds:
const handleSubmit = (text: string) => {
send(text);
setDraft(""); // consumer-owned clearing
};The composer starts on one line — the attach cluster, textarea, and send control sit on a single row. As the prompt grows to where it would reach the trailing controls (or on an explicit newline), it reflows to multiline: the textarea takes its own full-width row and the controls drop to the action row below. The switch is measured automatically and the textarea never unmounts, so focus and caret survive it. Pass multiline on PromptInput to start in that taller layout from the outset — for when the composer should read as a roomy text area before anyone types (pair with the textarea's rows for more height); the auto-reflow still grows it from there.
PromptInputSubmit auto-disables while the composer is empty (the IconButton's default dimmed treatment), and enables once there's text — or, in the default uncontrolled mode, an attached file — to send.
A leading Add (attach) button ships with PromptInput by default, and attaching files works out of the box: the + opens a native file picker, the composer is a dropzone (drop files and a "Drop your files here" overlay appears), and picked or dropped files become chips the composer manages for you — validated against accept / maxFileSize, with each rejection reported via onFileRejected. Those files arrive on onSubmit(text, files). Pass onFilesAdded to take control instead — you run the upload, render <PromptInputAttachments>, and drive each chip's status (the File uploads example below). Pass onAddAttachment to opt out of the built-in handling and wire the + to your own picker. hideAdd removes the button entirely.
In the textarea, Enter submits, Shift+Enter inserts a newline, and Enter during IME composition is left to the IME. Once multiline, vertical auto-grow is pure CSS (field-sizing-content up to max-h-48, then internal scroll). The composer deliberately has no invalid-state chrome — a chat composer has no validation story, so aria-invalid rings are suppressed by design.
The whole composer is one click target: pressing anywhere that isn't a control — the padding or the gaps around the buttons — focuses the textarea and drops the caret at the end, so a roomy multiline composer reads as a single input.
Composed buttons need an explicit type
Everything inside PromptInput is inside a form, and buttons default to type="submit" — a composed tool button without type="button" will submit the user's draft on click. PromptInputSubmit and the attachment remove control already handle their own types.
Composition
Use the following composition to build a PromptInput:
PromptInput (form — onSubmit, status, onStop; built-in Add button)
├── PromptInputAttachments (optional chips row, on top)
│ └── PromptInputAttachment (× n)
├── PromptInputTextarea
└── PromptInputToolbar (groups the action controls)
├── PromptInputTools (optional extra leading controls)
└── PromptInputTools align="end" (trailing cluster)
└── PromptInputSubmit (the voice prop renders the built-in mic beside it)The built-in Add button renders at the leading edge automatically (ahead of any align="start" cluster) — hide it with hideAdd. PromptInputTools is the control cluster: the default (align="start") is the leading cluster (left of the textarea on one line, dropping left when multiline); align="end" is the trailing cluster that hugs the right edge (the submit control — and, with the voice prop, the built-in mic just left of it). status, onStop, and the empty-input flag flow from the root via context — compose PromptInputSubmit and PromptInputTextarea anywhere inside the form without prop drilling.
Examples
Streaming status
While status="streaming": submission is suppressed entirely (Enter is a silent no-op — no submit, and no stray newline queued for when the stream ends), the textarea stays enabled so users can type ahead, and PromptInputSubmit cross-fades from the send glyph to a stop glyph that fires onStop. Toggle the switch in the hero demo above to watch the swap.
Multiline
Pass multiline to start the composer in its taller layout — the textarea on its own full-width row with the controls beneath — before anyone types. Pair it with the textarea's rows to set that resting height. The automatic reflow still applies on top, so it can only grow from there, never collapse back to one line.
<PromptInput multiline onSubmit={handleSubmit}> <PromptInputTextarea value={draft} onChange={(event) => setDraft(event.target.value)} placeholder="Describe the scenario you want to explore…" rows={3} /> <PromptInputToolbar> <PromptInputTools align="end"> <PromptInputSubmit /> </PromptInputTools> </PromptInputToolbar></PromptInput>Attachments
By default the composer manages the attachment list for you (try the + button or drop a file in the demo at the top of the page). To own the list yourself — to source chips elsewhere, label them, or skip the built-in picker — pass onAddAttachment and render your own <PromptInputAttachments>. That opts out of the built-in picker / dropzone: the + calls your handler and you control the chips (pass onRemove per chip, or omit it for a chip with no remove control). Here onAddAttachment appends a chip from a fixed pool.
<PromptInput onSubmit={handleSubmit} onAddAttachment={addAttachment}> <PromptInputAttachments> {attachments.map((name) => ( <PromptInputAttachment key={name} name={name} onRemove={() => removeAttachment(name)} /> ))} </PromptInputAttachments> <PromptInputTextarea placeholder="Add a note for the file…" /> <PromptInputToolbar> <PromptInputTools align="end"> <PromptInputSubmit /> </PromptInputTools> </PromptInputToolbar></PromptInput>File uploads
For real uploads, pass onFilesAdded to take control of the list. The built-in picker + dropzone stay on (as they are by default), but instead of the composer managing chips, you receive the valid files — render <PromptInputAttachments>, run the upload, and drive each chip's status. Files are still validated against accept and maxFileSize first; rejects fire onFileRejected (wire it to a toast). Each chip carries a status — uploading shows a spinner in place of the paperclip, error shows a danger chip. This demo accepts images and PDFs up to 5 MB — drop a file whose name contains "fail" to see the error state.
<PromptInput onSubmit={handleSubmit} onFilesAdded={handleFilesAdded} onFileRejected={(file, reason) => toast.error(messageFor(reason, file))} accept="image/*,application/pdf" maxFileSize={5 * 1024 * 1024}> <PromptInputAttachments> {attachments.map((file) => ( <PromptInputAttachment key={file.id} name={file.name} status={file.status} onRemove={() => removeAttachment(file.id)} /> ))} </PromptInputAttachments> <PromptInputTextarea placeholder="Drop a file, or use + to attach…" /> <PromptInputToolbar> <PromptInputTools align="end"> <PromptInputSubmit /> </PromptInputTools> </PromptInputToolbar></PromptInput>Voice messages
Pass voice on PromptInput to add the built-in voice-message control. The mic renders beside the send button, just left of it — the leading edge stays reserved for the Add button alone. Idle it's a mic; while recording it becomes a live timer with a level-reactive dot plus discard / finish actions, and the composer guards itself: the send control disables, Enter-to-submit is suppressed, and the form won't submit until the take is finished or discarded — no consumer wiring. The finished clip rides the same attachment pipeline as the picker — controlled → onFilesAdded, uncontrolled → a managed chip, so it sends like any other attachment (try it: record, finish, send). Recordings are validated like any file — accept, if set, must permit audio (MIME parameters are stripped, so a codecs=opus clip matches a plain audio/webm token) and maxFileSize applies. The control renders nothing where the browser can't record.
Nothing submitted yet.
<PromptInput voice onSubmit={handleSubmit}> <PromptInputTextarea placeholder="Record a voice note, or type…" /> <PromptInputToolbar> <PromptInputTools align="end"> <PromptInputSubmit /> </PromptInputTools> </PromptInputToolbar></PromptInput>Custom voice clip handling
To take the finished clip yourself instead of the attachment pipeline, compose PromptInputVoiceButton directly — in the trailing cluster, before PromptInputSubmit (don't pair it with the voice prop, or two mics render) — and pass onRecorded: this demo previews the recording in an AudioAttachment player. The composed button reports its recording state into the composer, so the send guards apply the same way. The underlying useAudioRecorder hook is exported for bespoke recording UIs.
function VoiceComposer() { const [clip, setClip] = React.useState(null); const handleRecorded = (file) => { setClip((prev) => { if (prev) URL.revokeObjectURL(prev.url); return { url: URL.createObjectURL(file), name: file.name }; }); }; return ( <VStack gap={2}> <PromptInput onSubmit={handleSubmit}> <PromptInputTextarea placeholder="Record a voice note, or type…" /> <PromptInputToolbar> <PromptInputTools align="end"> <PromptInputVoiceButton onRecorded={handleRecorded} /> <PromptInputSubmit /> </PromptInputTools> </PromptInputToolbar> </PromptInput> {clip ? <AudioAttachment src={clip.url} name={clip.name} status="ready" /> : null} </VStack> );}Accessibility
PromptInputSubmitcarries a status-aware accessible name:"Send message"while ready,"Stop generating"while streaming (override witharia-label). Both glyphs stay mounted and cross-fade, so SSR output and reduced-motion rendering are identical — reduced motion swaps the animation values for an instant cut.- Each attachment's remove button is named
"Remove {name}"from the chip'sname. - Enter-to-submit respects IME composition (
isComposingguard) — committing text with Enter in a composition never sends the message.
Props
| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
PromptInput | onSubmit | (text: string, files: File[]) => void | — | Called with the trimmed text and (uncontrolled mode) the attached files; ignored when both are empty. The textarea is never auto-cleared; the built-in attachment list is cleared on submit. |
PromptInput | status | "ready" | "streaming" | "ready" | "streaming" suppresses submission and flips the submit control to stop. |
PromptInput | onStop | () => void | — | Fired when the submit control is clicked while streaming. |
PromptInput | multiline | boolean | false | Forces the multiline layout even when empty, for a taller resting composer (pair with the textarea rows). The auto-reflow still applies on top — it can only grow, never collapse back to one line. |
PromptInput | onAddAttachment | () => void | — | Opt out of built-in file handling: the + calls this (your own picker) and the dropzone / managed chips are disabled. You render your own <PromptInputAttachments>. |
PromptInput | hideAdd | boolean | false | Hide the built-in leading Add button (compose your own leading controls instead). |
PromptInput | voice | boolean | false | Render the built-in voice-message control beside the send button (just left of PromptInputSubmit). While recording, send disables, Enter is suppressed, and submit is blocked; the finished clip rides the attachment pipeline. Don't pair with a composed PromptInputVoiceButton. |
PromptInput | onFilesAdded | (files: File[]) => void | — | Take control of the list (the picker + dropzone are on by default). Called with the valid files; you render <PromptInputAttachments>, run the upload, and drive each chip's status. Omit for the uncontrolled default (the composer manages chips). |
PromptInput | hasAttachments | boolean | false | Controlled mode only: set when your list has something submittable, so an attachment-only message (no text) can send — enables PromptInputSubmit and passes the empty-text submit guard. |
PromptInput | onFileRejected | (file: File, reason: "type" | "size") => void | — | Called once per file rejected by accept / maxFileSize. Wire to a toast. |
PromptInput | accept | string | — | Native-input accept syntax (e.g. "image/*,application/pdf"). Unset → any type. |
PromptInput | maxFileSize | number | — | Max file size in bytes. Unset → no limit. |
PromptInput | multiple | boolean | true | Allow multiple files (drop + multi-select). false keeps only the first. |
PromptInput | dropLabel | string | "Drop your files here" | Dropzone overlay copy. |
PromptInputTextarea | — | Textarea props | placeholder="How can I help you today?", name="message", rows={1} | The house Textarea, reskinned borderless — the shell owns the chrome and the one-line ↔ multiline reflow. |
PromptInputToolbar | — | div props | — | Groups the action clusters; a layout passthrough so they flank the textarea on one line and drop below it when multiline. |
PromptInputTools | align | "start" | "end" | "start" | Control cluster — "start" leads (left of the textarea), "end" trails (right edge — submit). Give composed buttons type="button". |
PromptInputSubmit | variant / size | IconButton variants | "default" / "sm" | Send/stop control; auto-disables while the textarea is empty. type and aria-label follow the status automatically. |
PromptInputVoiceButton | onRecorded | (file: File) => void | — | Handle the finished clip yourself instead of adding it via the shared file pipeline. Compose the button directly (trailing cluster, before PromptInputSubmit) for this — the voice prop always uses the pipeline. |
PromptInputVoiceButton | onRecordingChange | (recording: boolean) => void | — | Fired when recording starts / stops. The composer already gates send / Enter / submit off the recording state — reach for this only to react outside the composer. |
PromptInputVoiceButton | disabled | boolean | disabled while streaming | Disable starting a recording. Defaults to disabled while status="streaming"; an explicit value wins. Finish / discard stay active mid-recording. |
PromptInputAttachments | — | div props | — | Chips row above the textarea. |
PromptInputAttachment | name | string | — | Chip label; also names the remove button. Required. |
PromptInputAttachment | status | "uploading" | "ready" | "error" | "ready" | Upload lifecycle — uploading swaps the paperclip for a spinner; error renders a danger chip. |
PromptInputAttachment | onRemove | () => void | — | Renders the remove button when provided. |