PromptInput
AI chat input with auto-grow textarea, send/stop swap, a "+" options menu, @-mention and /-command typeahead, model selector, suggestion chips, drag-drop attachments, voice controls, inline & stacked layouts, RTL and a loading skeleton.
pnpm add @tessinaui/uiWhen to use
- A conversational surface. The composer is an auto-growing message field with the controls that send, attach to and configure one turn.
- Report generation state. Set
isGeneratingfor the whole time a response is streaming: the Stop button is the only way to cancel, and its transitions are what get announced. - Set
maxLengthonly when the limit is real. Past it, sending is refused by key and by button, which is a hard stop for the user. - Keep the controls to this turn. Attach, model, modes and voice configure what is sent now; anything else belongs elsewhere on the page.
- Wire the work yourself. The composer never uploads a file, transcribes
audio or calls a model. Report each result back through props such as
attachment
status, and it renders and announces that state.
When not to use
- For a plain multi-line form field. Use Textarea.
- For a search field with a submit affordance. Use Search.
- For a command palette over actions. Use Command.
- For the message bubbles themselves. Use ChatBubble.
- For a standalone drop zone with a file list. Use FileUpload.
- Don't wrap the controls in
role="toolbar", and don't rename a toggle when it flips ("Voice input" to "Stop voice input"). A constant name with state onaria-pressedis the toggle contract screen readers rely on. - Don't set
tone="on-color"on a neutral background. It derives its ink fromcurrentColor, so it needs a real coloured plate, usually aSurface.
Installation
pnpm add @tessinaui/uiUsage
import { PromptInputBar } from "@tessinaui/ui";<PromptInputBar
placeholder="Ask anything…"
onSend={(value, attachments) => console.log(value, attachments)}
/>The preview above and the Default example are the composed form: PromptInput
with rounded="full" and the parts placed by hand. Reach for PromptInputBar
when the props cover your toolbar.
The composer is a controlled display surface: it never uploads a file, calls a
model, or streams a response. You report state to it (isGenerating, attachment
status) and it renders and announces that state.
Examples
Default
The prompt box of an AI app, composed from the parts: a placeholder that opens
/ commands and @ context, a plus menu, two tool toggles (web search and
extended thinking), a model pill that carries its level, dictation and a round
Send. The toolbar answers to the box's own width: narrower, the two toggles fold
into one modes menu, and then into the plus menu. Every part is yours to move or
drop; PromptInputBar, below, is the same box from props when you do not need
that control.
Inline layout
One row — textarea and controls share a line. rounded="full" caps at the panel
radius (24px) here too: an inline bar wraps to a second row as soon as it carries
a model pill and a couple of toggles, and a pill radius on a wrapped box reads as
broken rather than round.
Toolbar controls
Model selector
Modes
The modes a prompt runs with: quiet icon toggles while the box is wide, one menu of switch rows as it narrows, then rows in the plus menu. The menu stays open while several modes flip, and a toggle never changes its name with its state.
Select pills
The pill is a generic single-select, not a model-only control — the assistants
use the same shape for an answer mode, a scope and a model. Give an option a
meta value for a second axis on the trigger (<model name> + "Extended"), an icon
to prefix it, and pass children to append settings rows to the menu.
In PromptInputBar's stacked layout the pill sits in the row of leading
controls, which scrolls when they do not fit, so the pill keeps its label and
the row scrolls. In a toolbar you compose yourself nothing scrolls, so a
crowded toolbar truncates the label instead. On a touch screen the pill's whole
width is a 44px target.
Suggestions
Mentions and commands
Type @ to mention someone or / for a command. The list opens where the
character starts a word, filters as you type, and inserts plain text, so the
value stays a string. Here @ filters a fixed list and / loads its entries
from a function.
Attachments
Drag a file onto the bar, paste an image, or use the paperclip. Report transfer
state per file with status, progress and errorMessage, and describe an
image with alt. A file that breaks accept, maxFiles or maxFileSize
stays in the list as an error card that states the rule.
Uploads, cancel and retry
With onAttachmentCancel, the × on an uploading file cancels it. With
onAttachmentRetry, a failed upload offers Retry. You run the upload; the
composer shows its state.
Voice recording bar
Tap the mic. The composer swaps to a recorder with a live level meter, and Confirm reports the length. The capture here is simulated.
Generating and stopping
While isGenerating is set, Send becomes Stop. Give Stop a progress (0–100)
and a ring around it fills as the reply arrives: your own measure, such as
tokens received against an expected length or steps done of a plan. Without
progress there is no ring. The ring sits where the focus ring would, and a
focused Stop moves it one ring further out so the two never overlap.
Submit shortcut
Character counter
Banner
Labelled send
On a coloured surface
On a plate the composer's well is the only wash under text: attachment cards are hairlines inside it, and an open toolbar control — or the modes trigger while any mode is on — becomes the plate's inverse chip.
RTL
Skeleton
Compound API
Compose the parts yourself when the bar's slots are not the arrangement you want.
APIs
Two APIs are available depending on how much control you need.
| API | When to use |
|---|---|
PromptInputBar | Drop-in single component for the 90% use case |
PromptInput + sub-components | Full control over toolbar layout and custom actions |
PromptInputBar
The batteries-included compound. Manages value and attachment state, auto-clears on send, and composes every sub-component behind opt-in props.
<PromptInputBar
placeholder="Message…"
showMenu
showVoice
models={MODELS}
suggestions={["Summarize", "Translate", "Explain"]}
accept="image/*,.pdf"
maxLength={500}
isGenerating={streaming}
onSend={(value, files) => send(value, files)}
onStop={() => abort()}
/>Compound API
Use PromptInput as the root and compose sub-components for full layout control.
import {
PromptInput,
PromptInputAttachments,
PromptInputTextarea,
PromptInputToolbar,
PromptInputMenu,
PromptInputModelSelect,
PromptInputSpacer,
PromptInputVoice,
PromptInputCounter,
PromptInputSend,
} from "@tessinaui/ui";
function CustomInput() {
const [value, setValue] = React.useState("");
return (
<PromptInput
value={value}
onChange={setValue}
onSend={(v) => { console.log(v); setValue(""); }}
accept="image/*,.pdf"
rounded="xl"
>
<PromptInputAttachments />
<PromptInputTextarea placeholder="Message…" maxRows={6} />
<PromptInputToolbar>
<PromptInputMenu />
<PromptInputModelSelect models={MODELS} defaultValue="sonnet-4-6" />
<PromptInputSpacer />
<PromptInputVoice />
<PromptInputCounter />
<PromptInputSend />
</PromptInputToolbar>
</PromptInput>
);
}Layout
layout="stacked" (default) places the toolbar in a row beneath the textarea.
layout="inline" collapses the textarea and controls onto a single row — the
compact assistant-style pill. In the compound API, wrap the row in PromptInputRow.
<PromptInputBar layout="inline" rounded="full" showMenu showVoice />Modes menu
Pass modes and they render as switch rows (role="menuitemcheckbox") in a
dropdown behind a sliders trigger. The menu stays open while several modes
flip; the trigger adopts the AA-validated primary tint while any mode is on.
For a primary mode that is one-of-many (an answer-mode "Search ▾"), use
PromptInputModelSelect instead — it is the generic single-select pill.
<PromptInputBar
showMenu
showAttach
models={MODELS}
modes={[
{ id: "search", icon: <Globe />, label: "Search" },
{ id: "think", icon: <Lightbulb />, label: "Think" },
]}
/>Options menu
showMenu adds a "+" dropdown. With no menuItems it ships a default AI-tool set
(Add photos & files, Create image, Thinking, Deep research, Web search). When the
input is attachment-enabled, "Add photos & files" opens the file picker.
<PromptInputBar
showMenu
menuItems={[
{ label: "Attach image", icon: <ImageIcon />, onSelect: pickImage },
{ label: "Search the web", icon: <Globe />, onSelect: searchWeb },
]}
/>Model selector
Pass a models list to render a model-picker pill in the toolbar. It is
controlled with model / onModelChange, or uncontrolled with defaultModel.
Picking an option closes the menu at once, and the chosen row ends in a check.
In the compound API, PromptInputModelSelect also takes moreModels for a
"More models" submenu, children for a settings switch or a link after the
options, and indicator="dot" for a leading dot instead of the check.
const MODELS = [
{ value: "opus-4-7", label: "Opus 4.7", description: "Most capable" },
{ value: "sonnet-4-6", label: "Sonnet 4.6", description: "Balanced" },
{ value: "haiku-4-5", label: "Haiku 4.5", description: "Fastest" },
];
<PromptInputBar models={MODELS} defaultModel="sonnet-4-6" onModelChange={setModel} />Suggestions
suggestions renders a horizontally-scrollable chip row below the bar.
suggestionBehavior="fill" (default) drops the text into the input;
"send" submits it immediately. The chips inherit the bar's size and
rounded so they stay visually consistent with the input.
<PromptInputBar
suggestions={["Summarize a document", "Write a function", "Brainstorm ideas"]}
suggestionBehavior="fill"
/>Mentions and commands
triggers opens a typeahead at the caret. Each trigger is one character and its
entries. Picking an entry replaces the character and the query with plain text.
<PromptInputBar
triggers={[
{
char: "@",
label: "People",
items: [{ value: "alice", keywords: ["alice@acme.com"] }, { value: "bob" }],
},
{
char: "/",
label: "Commands",
items: (query) => searchCommands(query),
emptyMessage: "No commands found",
},
]}
/>- The value stays plain text.
onSendreceives"Ask @alice to /summarize", with no token structure. Parse the string if you need one. Picked entries are not drawn as pills. - The list opens only where the character starts a word, so typing
alice@acme.comnever opens it. A space closes it. - A function
itemsis called with the query afterdebounceMs(150 ms by default) and can return a promise. An answer to an older query is dropped. serializechanges the inserted text, which is`${char}${value} `by default.emptyMessagekeeps the list open with a note when nothing matches.
Attachments
Set accept (and optionally maxFiles / maxFileSize) to enable attachments.
Files can be added by drag-and-drop, clipboard paste, the "+" menu
or the paperclip, which is the keyboard path. Previews render above the
textarea, and a message can be sent with attachments only — no text required.
<PromptInputBar
showAttach
accept="image/*,.pdf,.txt"
maxFiles={4}
maxFileSize={20 * 1024 * 1024}
onSend={(text, files) => send(text, files)}
onFilesRejected={(files, rejections) => log(rejections.map((r) => r.message))}
/>Attachment state can also be controlled with attachments / onAttachmentsChange.
A rejected file stays on screen. It joins the list as an error card whose
errorMessage states the rule — "PDF over 20 MB", "CSV files aren't accepted",
"Only 4 files allowed" — and carries a rejection code and no file. A
rejection card is never sent, never counts toward maxFiles and offers no
retry; the user removes it. In controlled mode onAttachmentsChange receives
these cards too, so skip anything with rejection when you upload.
Uploads are yours. The composer never uploads: set status: "uploading"
with progress while a file transfers, status: "error" with an
errorMessage when it fails, and clear status when it is done.
onAttachmentCancel(attachment)turns the × on an uploading card into "Cancel upload of <name>". Abort the transfer in the handler; the card leaves the list. Without it, the × only removes.onAttachmentRetry(attachment)shows Retry on a failed card that is not a rejection. Set the attachment back to"uploading"in the handler.
<PromptInputBar
showAttach
attachments={attachments}
onAttachmentsChange={(next) => {
setAttachments(next);
next.filter(isNewUpload).forEach(upload);
}}
onAttachmentCancel={(a) => controllers.get(a.id)?.abort()}
onAttachmentRetry={(a) => upload(a)}
/>Voice & voice mode
showVoice adds a microphone (dictation) button — pass isRecording for the
pulsing state. showVoiceMode adds the solid voice-mode button.
<PromptInputBar
showVoice
showVoiceMode
isRecording={recording}
onVoice={toggleDictation}
onVoiceMode={openVoiceMode}
/>Recording bar
Give isRecording a recording handler and the composer becomes a recorder: the
textarea and toolbar swap for Cancel, a level meter, the elapsed time and
Confirm. The draft is kept and comes back when the bar closes.
Recording is controlled. The composer never opens a microphone — start
capture in onVoice, set isRecording once it is really running (permission
can fail), and clear it from onRecordingCancel / onRecordingConfirm.
Feed recordingElapsed and recordingAmplitude from your own pipeline; without
them the bar counts from when it appeared and its bars idle on a fixed shape.
<PromptInputBar
showVoice
isRecording={recording}
onVoice={startCapture}
recordingElapsed={seconds}
recordingAmplitude={levels} // 0–1, newest last
onRecordingCancel={discardCapture}
onRecordingConfirm={sendVoiceNote}
/>Without a recording handler, isRecording only pulses the mic, so dictation
straight into the textarea keeps working. In the compound API, render
PromptInputRecording in place of the textarea and toolbar yourself.
Only tap-to-start ships. Hold-to-record with slide-to-cancel is a gesture with no keyboard path, so it would need a button twin anyway — which is this.
Send / Stop swap
Pass isGenerating={true} to swap the send button for a stop button. The
textarea stays editable while generating so the next message can be composed,
and both the start and the end of a generation are announced politely.
disabled outranks isGenerating: a disabled composer cannot be stopped from
here either.
<PromptInputBar
isGenerating={isStreaming}
onSend={(v) => startStream(v)}
onStop={() => abortStream()}
/>Character counter
maxLength enables a live counter that turns warning past 80% and error
past the limit. Typing past the limit is never blocked — a browser cannot
gracefully truncate pasted or dictated text — but sending is refused until
the value fits, by key and by button. Screen readers hear the count only once
typing pauses, and only after the threshold.
<PromptInputBar maxLength={500} showCounter />Banner
banner renders a dismissible notice at the top of the bar — handy for usage
notes or model warnings. bannerIntent (error, warning, success, info) tints it, and an error or warning notice is also spoken once when it appears.
<PromptInputBar
banner="Opus consumes usage limits faster than other models."
bannerIcon={<Sparkles />}
onBannerDismiss={dismiss}
/>Skeleton
Render PromptInputSkeleton while the interface loads. It mirrors the bar's
size, rounded and layout — every box comes from the maps the real parts
read, so the two cannot drift.
It is silent by default: pass label only when this placeholder is the
thing that should announce the wait.
<PromptInputSkeleton layout="stacked" />
<PromptInputSkeleton layout="inline" rounded="full" />
<PromptInputSkeleton label="Loading composer" />RTL
Pass dir="rtl" to PromptInput or PromptInputBar. The layout, toolbar,
menu, attachments and suggestion rows all mirror automatically.
Left unset, no dir is stamped at all and the composer inherits the document's
direction — so an RTL page needs nothing here.
An explicit dir="rtl" also switches the attachment strings to Arabic: card
actions and states, announcements, the rules a rejected file broke, the drop
overlay and the Attach button's name. Counts take Arabic plural forms. Pass
strings to replace any entry in any language.
<PromptInputBar dir="rtl" placeholder="اكتب رسالتك…" onSend={handleSend} />Keyboard behaviour
| Key | Action |
|---|---|
Enter | Submit — calls onSend (when submitOn="enter", the default) |
Shift + Enter / Alt + Enter | Insert a newline |
⌘/Ctrl + Enter | Submit when submitOn="mod-enter" |
| IME composing | Enter never submits mid-composition, in every mode |
↑ / ↓ with a typeahead open | Move through the entries |
Enter / Tab with a typeahead open | Insert the active entry. Enter does not send |
Escape with a typeahead open | Close the list and keep the text |
Tab / Shift + Tab | Move through the toolbar controls — each is its own stop |
Space / Enter on a toggle | Flip a mode, voice or voice-mode button |
Escape in the recording bar | Cancel the recording |
The toolbar is deliberately not role="toolbar". That pattern obliges
arrow-key navigation and a single tab stop, and the APG explicitly warns against
placing an arrow-consuming control — a textbox, which is exactly the inline
layout — inside one. Every surveyed kit leaves these as ordinary buttons.
Making Enter a newline on phones. No platform documents Return-to-send on a
soft keyboard, and no surveyed system makes touch-newline a default. Opt in
yourself with submitOn="none" below md, which leaves the Send button as the
only path:
const isPhone = useMediaQuery("(max-width: 767px)");
<PromptInputBar submitOn={isPhone ? "none" : "enter"} onSend={send} />API Reference
PromptInputBar — core
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Controlled value |
defaultValue | string | "" | Uncontrolled initial value |
onChange | (value: string) => void | — | Fires on every keystroke |
onSend | (value: string, attachments: PromptInputAttachment[]) => void | — | Called on Enter or Send; value + attachments auto-clear |
onStop | () => void | — | Called when the Stop button is pressed |
isGenerating | boolean | false | Swaps Send for Stop |
size | "sm" | "md" | "lg" | "md" | Density. Text steps 12 / 14 / 16 on desktop, floored at 16 on phones |
rounded | "none" | "sm" | "md" | "lg" | "full" | "lg" | Border-radius — applied to the container and every inner control |
layout | "stacked" | "inline" | "stacked" | Toolbar-below vs single-row layout |
disabled | boolean | false | Disables all interactions |
dir | "ltr" | "rtl" | — | Text direction. Unset, the composer inherits the page's |
tone | "default" | "on-color" | inherited | On a coloured or dark plate the well derives its ink from currentColor. Inherited from a wrapping <Surface>, else "default"; an explicit prop wins |
submitOn | "enter" | "mod-enter" | "none" | "enter" | Which keystroke submits. "none" leaves the button as the only path |
placeholder | string | "Message…" | Textarea placeholder |
maxRows | number | 8 | Lines before the textarea scrolls |
maxLength | number | — | Character limit; enables the counter. Typing past it is never blocked, but sending is refused until the value fits |
showCounter | boolean | false | Force-show the counter without maxLength |
triggers | PromptInputTrigger[] | — | Characters that open a typeahead at the caret, such as @ and /. Inserts plain text |
PromptInputBar — toolbar slots
| Prop | Type | Default | Description |
|---|---|---|---|
showMenu | boolean | false | Show the "+" options menu |
menuItems | PromptInputMenuItem[] | default set | Custom menu items |
showAttach | boolean | false | Show the paperclip attach button |
onAttach | () => void | — | Overrides the attach button click |
showVoice | boolean | false | Show the microphone button |
onVoice | () => void | — | Called when the mic is clicked |
isRecording | boolean | false | Recording is live — pulses the mic and announces start / stop; with a recording handler, shows the recording bar |
onRecordingCancel | () => void | — | Discards the recording (Cancel, or Escape in the bar) |
onRecordingConfirm | () => void | — | Keeps the recording (Confirm) |
recordingElapsed | number | — | Seconds recorded. Omitted, the bar counts from when it appeared |
recordingAmplitude | number[] | — | Live levels, 0–1, newest last. Omitted, the bars idle |
showVoiceMode | boolean | false | Show the solid voice-mode button |
onVoiceMode | () => void | — | Called when voice mode is clicked |
isVoiceModeActive | boolean | false | Active state on the voice-mode button |
models | PromptInputModelOption[] | — | Renders the model-selector pill |
model / defaultModel | string | — | Controlled / uncontrolled model value |
onModelChange | (value: string) => void | — | Fires when the model changes |
modes | PromptInputMode[] | — | Modes (e.g. Search, Think) — switch rows in the sliders menu |
PromptInputBar — send, suggestions, banner & attachments
| Prop | Type | Default | Description |
|---|---|---|---|
sendText | ReactNode | — | Visible label on the Send button. Omitted, Send stays icon-only |
bannerIntent | "none" | "error" | "warning" | "success" | "info" | "none" | Banner meaning. error and warning notices are also spoken through the composer's live region when they appear or change |
PromptInputAttachment
| Field | Type | Description |
|---|---|---|
id | string | Stable identity — used as the React key and in announcements |
name | string | File name, shown and announced |
size | number | Bytes, formatted for display |
type | string | MIME type; image/* renders a thumbnail |
url | string | Object/data URL for the thumbnail |
alt | string | Describes an image thumbnail. Unset, the thumbnail is decorative |
file | File | The picked file. Absent on a rejection card |
status | "uploading" | "error" | Transfer state. Absent means settled |
progress | number | 0–100, shown as a determinate bar while uploading |
errorMessage | string | Shown in place of the size when status is "error" |
rejection | "file-invalid-type" | "file-too-large" | "too-many-files" | Set by the composer on a file it turned away. Never sent, not counted, no retry |
PromptInputTrigger
| Field | Type | Default | Description |
|---|---|---|---|
char | string | — | One character. Opens the list where it starts a word |
items | PromptInputTriggerItem[] | (query: string) => PromptInputTriggerItem[] | Promise<PromptInputTriggerItem[]> | — | The entries, or a function called with the query that does its own filtering |
label | string | "Suggestions" | Accessible name of the list |
minQueryLength | number | 0 | Characters typed after the trigger before the list opens |
debounceMs | number | 150 | Delay before a function items is called |
filter | (value: string, query: string) => boolean | substring | Match test for array items, checked against value and keywords |
serialize | (item) => string | `${char}${value} ` | The inserted text |
onSelect | (item) => void | — | Called after an entry is inserted |
emptyMessage | string | — | Shown when nothing matches. Unset, the list closes |
loadingMessage | string | "Loading…" | Shown while a function items has not answered |
PromptInputTriggerItem
| Field | Type | Description |
|---|---|---|
value | string | Inserted after the trigger, and what the query matches |
label | ReactNode | Row content. Defaults to the trigger plus value |
keywords | string[] | More terms the query matches |
description | string | A quieter second line, outside the option's accessible name |
icon | ReactNode | Leading icon |
disabled | boolean | Shown, but cannot be picked |
| Prop | Type | Default | Description |
|---|---|---|---|
suggestions | PromptInputSuggestion[] | — | Suggestion chips below the bar |
suggestionBehavior | "fill" | "send" | "fill" | Fill the input vs submit on click |
onSuggestionSelect | (value: string) => void | — | Fires when a chip is clicked |
banner | React.ReactNode | — | Dismissible banner content |
bannerIcon | React.ReactNode | — | Leading icon for the banner |
onBannerDismiss | () => void | — | Called when the banner is dismissed |
accept | string | — | Accepted file types; enables attachments |
maxFiles | number | — | Maximum attachment count |
maxFileSize | number | — | Maximum size per file, in bytes |
attachments / defaultAttachments | PromptInputAttachment[] | — | Controlled / uncontrolled attachment list |
onAttachmentsChange | (attachments: PromptInputAttachment[]) => void | — | Fires when attachments change |
onFilesRejected | (files: File[], rejections: PromptInputFileRejection[]) => void | — | Files rejected by the accept / size / count limits, each with its reason and message |
onAttachmentCancel | (attachment: PromptInputAttachment) => void | — | Makes an uploading card's × cancel. Abort the upload here; the card leaves the list |
onAttachmentRetry | (attachment: PromptInputAttachment) => void | — | Shows Retry on a failed card that is not a rejection |
strings | Partial<PromptInputStrings> | English, or Arabic under dir="rtl" | Replaces any attachment-flow string |
attachable | boolean | auto | Force-enable drag-drop + paste handling |
PromptInput (root)
Accepts all PromptInputBar props except the show* slot-visibility flags and
the model / suggestion / banner conveniences. Context is consumed by every
sub-component.
PromptInputMenu
| Prop | Type | Default | Description |
|---|---|---|---|
items | PromptInputMenuItem[] | default set | Menu items |
children | React.ReactNode | — | Custom menu content — overrides items |
icon | React.ReactNode | <Plus /> | Trigger icon |
side | "top" | "bottom" | "top" | Menu open direction |
align | "start" | "center" | "end" | "start" | Menu alignment |
triggerClassName | string | — | Classes on the trigger button |
PromptInputModelSelect
| Prop | Type | Default | Description |
|---|---|---|---|
models | PromptInputModelOption[] | — | Selectable models (required) |
value / defaultValue | string | — | Controlled / uncontrolled selection |
onValueChange | (value: string) => void | — | Fires on selection |
closeOnSelect | boolean | true | Close the menu once an option is picked, so a pick is one click |
indicator | "check" | "dot" | "check" | How the chosen option is marked: a trailing check, which starts every label at the row's own padding, or a leading dot |
menuSize | "xs" | "sm" | "md" | "lg" | "xl" | the bar's | The menu's density step. Set a smaller one for a compact menu over a roomy bar |
moreModels | PromptInputModelOption[] | — | Options behind a "More models" submenu, picked into the same value. The trigger shows the chosen one wherever it lives |
moreModelsLabel | string | "More models" | The submenu row's label |
children | React.ReactNode | — | Extra content appended inside the menu after the options: a settings switch, a link, a separator |
side / align | menu placement | "top" / "start" | Where the menu opens |
aria-label | string | "Select model" | The trigger's name |
PromptInputModes
| Prop | Type | Default | Description |
|---|---|---|---|
modes | PromptInputMode[] | — | The mode rows (required). Each: id, icon, label, active / defaultActive, onToggle |
icon | React.ReactNode | sliders | Trigger icon |
aria-label | string | "Prompt modes" | Trigger name — constant regardless of state |
side / align | menu placement | "top" / "start" | Where the menu opens |
triggerClassName | string | — | Classes on the trigger button |
PromptInputSuggestions
| Prop | Type | Default | Description |
|---|---|---|---|
suggestions | PromptInputSuggestion[] | — | Chips to render (required) |
behavior | "fill" | "send" | "fill" | Fill the input vs submit on click |
onSelect | (value: string) => void | — | Fires when a chip is clicked |
chipVariant | "solid" | "soft" | "outline" | "ghost" | "outline" | Chip fill style |
size | "sm" | "md" | "lg" | inherited | Chip size — inherits the bar's size |
rounded | "none" | "sm" | "md" | "lg" | "full" | inherited | Chip rounding — inherits the bar's rounded |
PromptInputSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "lg" | "md" | Mirrors the bar size |
rounded | "none" | "sm" | "md" | "lg" | "full" | "lg" | Mirrors the bar radius |
layout | "stacked" | "inline" | "stacked" | Mirrors the bar layout |
showToolbar | boolean | true | Render the toolbar placeholder row |
showBanner | boolean | false | Reserve the banner row above the textarea, matching banner |
showModel | boolean | false | Reserve the model select pill, matching models |
lines | number | 1 | Text lines in the textarea placeholder. Raise it to depict a drafted message |
label | string | — | Announces the loading region. Silent when omitted |
PromptInputSend
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | A visible label beside the icon. Without it Send is the square icon-only button; with it, the labelled CTA that form-shaped composers use |
progress | number | — | 0 to 100. While isGenerating, a ring around Stop fills with it. Report your own measure; the composer never estimates one |
sendLabel | string | "Send message" | The Send button's accessible name |
stopLabel | string | "Stop generation" | The Stop button's accessible name |
PromptInputBanner
| Prop | Type | Default | Description |
|---|---|---|---|
icon | React.ReactNode | — | Leading glyph |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Semantic meaning. An error or warning notice is also spoken through the composer's live region when it appears or its text changes, so a limit or quota notice that arrives mid-session is heard |
onDismiss | () => void | — | Shows the dismiss control |
dismissLabel | string | "Dismiss" | The dismiss control's accessible name |
PromptInputVoiceMode
| Prop | Type | Default | Description |
|---|---|---|---|
isActive | boolean | false | Voice mode is on |
PromptInputRecording
| Prop | Type | Default | Description |
|---|---|---|---|
onCancel | () => void | — | Discards the recording. Also bound to Escape anywhere inside the bar |
onConfirm | () => void | — | Keeps the recording |
elapsed | number | — | Seconds recorded so far. Omitted, the bar counts from its own mount |
amplitude | number[] | — | Live input levels, 0 to 1, newest last. Omitted, the bars idle on a fixed shape |
formatTime | (seconds: number) => string | 0:07 | Formats the timer text |
confirmIcon | React.ReactNode | an arrow | The Confirm glyph. The arrow suits a voice note; a check suits dictation |
label | string | "Voice recording" | The bar's accessible name |
cancelLabel · confirmLabel | string | "Cancel recording" · "Send recording" | The buttons' accessible names. Name what Confirm does with the recording |
PromptInputDropOverlay
| Prop | Type | Default | Description |
|---|---|---|---|
isVisible | boolean | — | Shows the overlay |
label | string | "Drop files to attach" | Overlay label text |
Accessibility
- One polite live region per composer, mounted empty from first paint (a region added at announcement time is never announced). It speaks: generation started and finished, recording started and stopped, files attached, rejected (with the rule) and removed by name, and entering or leaving the drop zone.
- Upload progress is spoken at intervals, not continuously — at most once every 10 seconds while a file uploads, and only when the percentage moved; a failure or a finished upload is spoken at once.
- The attachment rail is a list named "Attachments". Each card reads its
file name, its type ("Image", "PDF", "File") and its size or state. An image
with
altis announced as an image; without it the thumbnail is decorative, because the name beside it already names the card. - Removing or cancelling a card never drops focus to the page. Focus moves to the next card's ×, else the previous card's, else the textarea. After Retry, focus stays on the same card.
- The recording bar moves focus deliberately. Opening it focuses Confirm
(only when focus was already in the composer); closing it returns focus to the
textarea. It is a group named "Voice recording", and its level meter is
aria-hiddendecoration — the timer is the readable signal. - The counter follows the tested character-count model — the visible count is
aria-hiddenand a separate hidden polite region carries the announcement, debounced until typing pauses and silent until the threshold is crossed. - Toggles keep a constant name. Voice and voice-mode never rename
themselves between states; the state is on
aria-pressed, per the APG button pattern. Mode rows aremenuitemcheckbox— state onaria-checked, name constant. - The model pill announces its value, not just its purpose — "Select model, " followed by the chosen model's own name.
- Touch targets are ≥44×44 px via an extended hit area centred on each
compact control, and adjacent controls are spaced so those areas cannot
overlap (12 px below
md, giving exactly 44 px of pitch). The rule is simple: the painted icon stays small, the target does not. On a touch device the controls take their phone size at any width, so a tablet has room for the targets too, and the scrolling row of leading controls keeps room for them, so none is cut short. - The textarea renders at ≥16 px on phones at every size, so iOS never zooms on focus. It comes from the input-family type ladder, not a local rule.
enterKeyHintis stamped fromsubmitOn, so the soft keyboard's Return key is labelled for what it actually does.- Focus stays in the textarea while a typeahead is open. The list is a
listboxthat the textarea points into witharia-activedescendant, and the number of matches is announced through the composer's live region. The textarea keeps its textbox role, which does not allowaria-expanded.
Assistive-technology matrix
| Tool | Status |
|---|---|
| VoiceOver (macOS, Safari) | Passed 2026-09-14 — "Ask anything…, text entry area"; Send reads "Send message, dimmed button" while empty and "Send message, button" once there is text |
| NVDA | Untested |
| JAWS | Untested |
The mention and command typeahead has not been tested with a screen reader yet.
Migration
Nothing in the TypeScript API was removed or renamed. These behaviours changed:
| Change | Before | Now |
|---|---|---|
dir | Always stamped ltr | Unset unless you pass it — the composer inherits the page direction |
maxLength | Advisory only, despite the docs saying "hard limit" | Sending is refused while over the limit (key and button), and the overshoot is announced |
disabled + isGenerating | disabled was ignored while generating | disabled outranks everything, including Stop |
rounded="full" | 16 px | 24 px — the panel cap every other panel in the library uses |
| Desktop textarea size | 16px (the md: half of the old ladder never applied) | 14px, as the source always claimed — the composer is ~4px shorter |
PromptInputSkeleton | Always announced "Loading" | Silent unless you pass label |
| Model pill name | "Select model" | "Select model, <current model>" — update any name-regex query |
| Counter markup | One span flipping its own aria-live | Visible count + a separate hidden polite region |
A file rejected by accept / maxFiles / maxFileSize | Vanished; a callback and a spoken count | Stays as an error card stating the rule, with a rejection code — onAttachmentsChange receives it, onSend never does |
onFilesRejected | (files) | (files, rejections) — each rejection carries reason and message |
| Removing an attachment | Focus dropped to <body> | Focus moves to the next card, the previous one, or the textarea |
| Upload progress | Only failure and completion spoken | Also spoken every 10 seconds while the percentage moves |
| Attachment sizes | "20.0 MB" | "20 MB" — FileUpload's rounding |
Agent notes
Every part carries a stable data-slot (prompt-input, prompt-input-textarea,
prompt-input-send, prompt-input-attachment, prompt-input-announcer,
prompt-input-recording, …), and the root reflects state as data-generating,
data-recording, data-disabled, data-dragging,
data-over-limit, data-tone, data-size and data-layout. Target those
rather than class names or DOM position.
An open typeahead is [data-slot="prompt-input-typeahead"] (the listbox), its
rows are [data-slot="prompt-input-typeahead-item"], and the active row carries
data-highlighted.
Reading the composer's state from outside React: [data-prompt-input] is the
root, its textarea holds the draft, [data-slot="prompt-input-send"] carries
data-state="idle" | "generating", and [data-slot="prompt-input-attachment"]
rows carry data-status. A card's × is [data-slot="prompt-input-attachment-remove"]
(it cancels while uploading when onAttachmentCancel is set) and Retry is
[data-slot="prompt-input-attachment-retry"]. A rejection card is an attachment
with a rejection field and no file.
Sub-components
| Component | Description |
|---|---|
PromptInput | Root context provider — value, attachments, callbacks, size, layout, direction |
PromptInputBanner | Dismissible notice row at the top of the bar |
PromptInputTextarea | Auto-growing textarea — expands up to maxRows before scrolling |
PromptInputToolbar | Bottom action row (stacked layout) |
PromptInputRow | Single-row wrapper for the inline layout |
PromptInputSpacer | Spring that pushes the controls after it to the trailing edge: Spacer with grow. Forwards a ref and takes HTML props |
PromptInputAttach | Paperclip icon button — opens the file picker |
PromptInputMenu | "+" dropdown of options |
PromptInputModelSelect | Model-picker pill |
PromptInputVoice | Microphone / dictation button with pulsing recording state |
PromptInputVoiceMode | Solid voice-mode button |
PromptInputRecording | Recording bar — Cancel, level meter, timer, Confirm; replaces the textarea and toolbar while recording |
PromptInputModes | Sliders menu of mode switch rows (Search, Think) |
PromptInputCounter | Character count with limit colouring |
PromptInputSend | Send / Stop button — swaps based on isGenerating |
PromptInputDropOverlay | Full-surface drag-to-attach overlay (auto-rendered) |
PromptInputAttachments | Attachment list — thumbnail or file card with progress, and remove, cancel or retry |
PromptInputSuggestions | Predefined suggestion chip row |
PromptInputSkeleton | Loading placeholder mirroring the bar |
PromptInputBar | All-in-one compound wrapping every sub-component |