PromptInput
AI chat input with auto-grow textarea, send/stop swap, a "+" options menu, model selector, suggestion chips, drag-drop attachments, voice controls, inline & stacked layouts, RTL and a loading skeleton.
Playground
Installation
pnpm add @tessinaui/uiUsage
import { PromptInputBar } from "@tessinaui/ui";<PromptInputBar
placeholder="Ask anything…"
onSend={(value, attachments) => console.log(value, attachments)}
/>When to use
PromptInput is the composer for a conversational surface: an auto-growing
message field with the controls that send, attach to, and configure one turn.
Reach for something else when:
| You want | Use |
|---|---|
| A plain multi-line form field | Textarea |
| A search field with a submit affordance | Search |
| A command palette over actions | Command |
| The message bubbles themselves | ChatBubble |
| A standalone drop zone with a file list | FileUpload |
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 stacked bar: auto-growing textarea with a toolbar underneath.
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 menu
The modes a prompt runs with, as switch rows behind one sliders trigger — the shape the flagship assistants ship (Claude's sliders menu, ChatGPT's Tools menu). The menu stays open while several modes flip, and the trigger tints while any mode is on.
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 ("Sonnet 4.6 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
Attachments
Drag a file onto the bar, paste an image, or use the paperclip. Report transfer
state per file with status, progress and errorMessage.
Generating and stopping
Submit shortcut
Character counter
Banner
Labelled send
On a coloured surface
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 ChatGPT-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 (Perplexity's "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.
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"
/>Attachments
Set accept (and optionally maxFiles / maxFileSize) to enable attachments.
Files can be added by drag-and-drop, clipboard paste, or the "+" menu.
Previews render above the textarea with a remove button, and a message can be
sent with attachments only — no text required.
<PromptInputBar
showMenu
accept="image/*,.pdf,.txt"
maxFiles={4}
maxFileSize={5 * 1024 * 1024}
onSend={(text, files) => send(text, files)}
onFilesRejected={(rejected) => toast(`${rejected.length} file(s) rejected`)}
/>Attachment state can also be controlled with attachments / onAttachmentsChange.
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}
/>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.
<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.
<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 |
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 |
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} />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, files attached, rejected and removed by name, and entering or leaving the drop zone.
- The counter follows the GOV.UK 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, Sonnet 4.6".
- 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). This is the M3 model: 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.
Assistive-technology matrix
| Tool | Status |
|---|---|
| VoiceOver (macOS, Safari) | Verified |
| NVDA | Untested |
| JAWS | Untested |
Do and don't
Do
- Report
isGeneratingfor the whole time a response is streaming — the Stop button is the only way to cancel, and its transitions are what get announced. - Give
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 composer's own controls to what configures this turn.
Don't
- Don't put a
role="toolbar"wrapper around the controls — see above. - Don't rename a toggle when it flips ("Voice input" → "Stop voice input"); that breaks the toggle contract screen readers rely on.
- Don't rely on the composer to upload, transcribe or call a model. Wire those yourself and report the result back through props.
- Don't set
tone="on-color"on a neutral background — it derives its ink fromcurrentColor, so it needs a real coloured plate (usually aSurface).
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 |
Agent notes
Every part carries a stable data-slot (prompt-input, prompt-input-textarea,
prompt-input-send, prompt-input-attachment, prompt-input-announcer, …), and
the root reflects state as data-generating, data-disabled, data-dragging,
data-over-limit, data-tone, data-size and data-layout. Target those
rather than class names or DOM position.
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.
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 | flex-1 spacer to push items to either end |
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 |
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 | Inline attachment previews with remove buttons |
PromptInputSuggestions | Predefined suggestion chip row |
PromptInputSkeleton | Loading placeholder mirroring the bar |
PromptInputBar | All-in-one compound wrapping every sub-component |
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" | "default" | Set on a coloured or dark plate — the well derives its ink from currentColor |
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 |
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 | Pulsing recording state on the mic |
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 also make the row a polite live region |
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 |
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" |
| 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[]) => void | — | Files rejected by the accept / size / count limits |
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 |
PromptInputModelSelect
| Prop | Type | Default | Description |
|---|---|---|---|
models | PromptInputModelOption[] | — | Selectable models (required) |
value / defaultValue | string | — | Controlled / uncontrolled selection |
onValueChange | (value: string) => void | — | Fires on selection |
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 |
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 | "sm" | "md" | "lg" | "xl" | "full" | inherited | Chip rounding — inherits the bar's rounded |
PromptInputSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "lg" | "md" | Mirrors the bar size |
rounded | "sm" | "md" | "lg" | "xl" | "full" | "xl" | Mirrors the bar radius |
layout | "stacked" | "inline" | "stacked" | Mirrors the bar layout |
showToolbar | boolean | true | Render the toolbar placeholder row |
PromptInputDropOverlay
| Prop | Type | Default | Description |
|---|---|---|---|
isVisible | boolean | — | Shows the overlay |
label | string | "Drop files to attach" | Overlay label text |