PromptInput
The cornerstone compose surface for any AI chat. A form-rooted state machine with auto-resizing textarea, attachment chips, action menu, speech input, model selector and a status-driven submit button.
Overview
PromptInput is the largest primitive in @gremorie/rx-ai. It is a <form>-rooted state machine that composes an InputGroup from @gremorie/rx-forms (textarea + addons rendered as one cohesive surface) with the full set of chat-input affordances: file attachments with hover-card previews, an action menu, a speech-to-text button, a model selector, in-input action buttons and a status-driven submit button whose icon flips between arrow / spinner / stop / X depending on chat state.
State is managed in three layers:
- Self-managed (default). Everything lives inside
PromptInput. PassonSubmitand you are done. - Provider-lifted via
PromptInputProvider. ExposeusePromptInputController()to read or mutate the text and attachments from anywhere in the tree. - Controlled textarea. The provider's
textInput.value/textInput.setInputdrivePromptInputTextareaautomatically when the provider is present.
The component also carries an enforced keyboard contract: Enter submits, Shift+Enter newlines, Backspace on an empty textarea removes the last attachment, paste-with-files attaches automatically, IME composition events are honored. Submit is wired through form.requestSubmit() so HTML form validation still applies.
Installation
bash npx gremorie@latest add rx-prompt-input bash pnpm dlx gremorie@latest add rx-prompt-input
bash yarn dlx gremorie@latest add rx-prompt-input
bash bunx --bun gremorie@latest add rx-prompt-input
Brings in rx-forms (InputGroup family + Button + Select), rx-overlays (DropdownMenu + HoverCard + Tooltip), rx-display (Command + Dialog) and rx-core (the cn helper) as registry dependencies.
Usage
Minimal
"use client";
import { useState } from "react";
import {
type ChatStatus,
PromptInput,
PromptInputBody,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
} from "@gremorie/rx-ai";
export function ChatBox() {
const [status, setStatus] = useState<ChatStatus>("ready");
return (
<PromptInput onSubmit={(message) => send(message.text, message.files)}>
<PromptInputBody>
<PromptInputTextarea placeholder="Ask anything" />
<PromptInputFooter>
<span />
<PromptInputSubmit status={status} />
</PromptInputFooter>
</PromptInputBody>
</PromptInput>
);
}
import {
PromptInput,
PromptInputSubmit,
PromptInputTextarea,
PromptInputToolbar,
} from "@gremorie/ng-ai";
@Component({
selector: "app-chat-box",
standalone: true,
imports: [PromptInput, PromptInputTextarea, PromptInputToolbar, PromptInputSubmit],
template: `
<gremorie-prompt-input (gremoriePromptSubmit)="send($event)">
<gremorie-prompt-input-textarea placeholder="Ask anything" />
<gremorie-prompt-input-toolbar>
<gremorie-prompt-input-submit [status]="status()" />
</gremorie-prompt-input-toolbar>
</gremorie-prompt-input>
`,
})
export class ChatBoxComponent {
status = signal<ChatStatus>("ready");
}Full-featured
'use client';
import { useRef, useState } from 'react';
import {
type ChatStatus,
PromptInput,
PromptInputActionAddAttachments,
PromptInputActionMenu,
PromptInputActionMenuContent,
PromptInputActionMenuTrigger,
PromptInputAttachment,
PromptInputAttachments,
PromptInputBody,
PromptInputButton,
PromptInputFooter,
PromptInputSelect,
PromptInputSelectContent,
PromptInputSelectItem,
PromptInputSelectTrigger,
PromptInputSelectValue,
PromptInputSpeechButton,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
} from '@gremorie/rx-ai';
import { GlobeIcon } from 'lucide-react';
const models = [
{ id: 'gpt-4o', label: 'GPT-4o' },
{ id: 'claude-4.5', label: 'Claude 4.5' },
];
export function ChatBox() {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [status, setStatus] = useState<ChatStatus>('ready');
const [model, setModel] = useState('gpt-4o');
return (
<PromptInput
accept="image/*"
multiple
maxFileSize={5_000_000}
onSubmit={(message) => send(message.text, message.files)}
>
<PromptInputBody>
<PromptInputAttachments>
{(file) => <PromptInputAttachment data={file} />}
</PromptInputAttachments>
<PromptInputTextarea ref={textareaRef} placeholder="Ask anything" />
<PromptInputFooter>
<PromptInputTools>
<PromptInputActionMenu>
<PromptInputActionMenuTrigger aria-label="Add attachment" />
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
<PromptInputSpeechButton textareaRef={textareaRef} />
<PromptInputButton aria-label="Search the web">
<GlobeIcon />
Web
</PromptInputButton>
<PromptInputSelect value={model} onValueChange={setModel}>
<PromptInputSelectTrigger>
<PromptInputSelectValue placeholder="Model" />
</PromptInputSelectTrigger>
<PromptInputSelectContent>
{models.map((m) => (
<PromptInputSelectItem key={m.id} value={m.id}>
{m.label}
</PromptInputSelectItem>
))}
</PromptInputSelectContent>
</PromptInputSelect>
</PromptInputTools>
<PromptInputSubmit status={status} />
</PromptInputFooter>
</PromptInputBody>
</PromptInput>
);
}API
Types
| Type | Shape | Description |
|---|---|---|
ChatStatus | "ready" | "submitted" | "streaming" | "error" | The lifecycle states PromptInputSubmit reacts to. |
FileUIPart | { type: "file"; url: string; mediaType: string; filename?: string } | The Vercel AI SDK's file part shape. Exported here for convenience. |
AttachmentFile | FileUIPart & { id: string } | Internal representation that adds a stable ID. Used by the attachments context. |
PromptInputMessage | { text: string; files: FileUIPart[] } | What onSubmit receives. |
<PromptInput>
The root form. Manages attachments, file drag-drop and submit lifecycle. Use a PromptInputProvider parent to lift state outside.
| Prop | Type | Default | Description |
|---|---|---|---|
onSubmit | (message: PromptInputMessage, event: FormEvent) => void | Promise<void> | - | Required. Fires on form submit (Enter key, Submit click, or requestSubmit). |
accept | string | - | Comma-separated MIME pattern ("image/*,application/pdf"). Validates pasted and dropped files. |
multiple | boolean | - | Allow more than one file per pick. |
globalDrop | boolean | false | When true, accepts drops anywhere on the document. Default scope is the form element. |
syncHiddenInput | boolean | false | Clear the hidden file input when the attachments list goes to 0. Useful for re-picking the same filename. |
maxFiles | number | - | Cap on total attachments. Extra files trigger onError({ code: "max_files" }). |
maxFileSize | number | - | Per-file byte cap. Files over the limit trigger onError({ code: "max_file_size" }). |
onError | (err: { code: "max_files" | "max_file_size" | "accept"; message: string }) => void | - | Fires when validation rejects files. |
className | string | - | Extra classes on the underlying InputGroup. |
children | ReactNode | - | Typically PromptInputBody and its descendants. |
<PromptInputProvider>
Optional provider that lifts text and attachment state above PromptInput. Wrap your tree once near the root, then any descendant can use usePromptInputController() or useProviderAttachments().
| Prop | Type | Default | Description |
|---|---|---|---|
initialInput | string | "" | Seed value for the controlled textarea. |
children | ReactNode | - | The tree that should observe the lifted state. |
<PromptInputBody> / <PromptInputHeader> / <PromptInputFooter> / <PromptInputTools>
Layout primitives.
PromptInputBodywraps children indisplay: contentsso they flow directly into theInputGroup.PromptInputHeaderis anInputGroupAddonpinned toalign="block-end"withorder-first flex-wrap gap-1. Use for header-style toolbars (above the textarea).PromptInputFooteris anInputGroupAddonpinned toalign="block-end"withjustify-between gap-1. Default home for tools + submit.PromptInputToolsis aflex items-center gap-1row meant to live inside the footer.
All extend their underlying DOM element props.
<PromptInputTextarea>
The composer. Auto-resizes between min-h-16 and max-h-48. Default name="message" so it serializes as the form's text field.
| Prop | Type | Default | Description |
|---|---|---|---|
placeholder | string | "What would you like to know?" | Placeholder text. |
value / onChange | controlled props | - | When provided, the textarea is fully controlled. Otherwise the provider's text input drives it (if any). |
Built-in keyboard contract:
| Key | Behavior |
|---|---|
Enter | requestSubmit() (skipped if Submit is disabled) |
Shift+Enter | Newline |
Backspace on empty textarea | Removes the most recent attachment |
| IME composition | Enter is ignored while composing |
| Paste with files | Attaches files automatically |
<PromptInputAttachments> / <PromptInputAttachment>
| Component | Purpose |
|---|---|
PromptInputAttachments | Container that renders one child per attachment via a function child. Renders nothing when the list is empty. |
PromptInputAttachment | A single chip with a hover-card preview (image thumbnail or paperclip icon) and a remove button. |
PromptInputAttachments:
| Prop | Type | Default | Description |
|---|---|---|---|
children | (attachment: AttachmentFile) => ReactNode | - | Required. Render function. |
className | string | - | Extra classes on the wrapper. |
PromptInputAttachment:
| Prop | Type | Default | Description |
|---|---|---|---|
data | AttachmentFile | - | Required. The attachment to render. |
className | string | - | Extra classes on the chip. |
<PromptInputButton> / <PromptInputSubmit>
PromptInputButton wraps InputGroupButton. Picks size="icon-sm" for single-child icons and size="sm" for icon+label, unless you set size explicitly. Default variant="ghost".
PromptInputSubmit is a status-driven InputGroupButton whose icon depends on status:
status | Icon | Behavior |
|---|---|---|
"ready" | CornerDownLeftIcon | Submit when clicked. Disabled when the textarea is empty. |
"submitted" | Loader2Icon (spinning) | Disabled. |
"streaming" | SquareIcon | Click to stop generation (host wires up the handler). |
"error" | XIcon | Click to retry (host wires up the handler). |
undefined | CornerDownLeftIcon | Same as "ready". |
Carries aria-label="Submit".
Action menu family
PromptInputActionMenu, PromptInputActionMenuTrigger, PromptInputActionMenuContent and PromptInputActionMenuItem are thin wrappers around DropdownMenu, DropdownMenuTrigger, DropdownMenuContent and DropdownMenuItem. The trigger defaults to a PromptInputButton with a PlusIcon.
PromptInputActionAddAttachments is a pre-wired DropdownMenuItem that calls attachments.openFileDialog() on select. Default label: "Add photos or files". Accepts a label prop to override.
<PromptInputSpeechButton>
Web Speech API toggle.
| Prop | Type | Default | Description |
|---|---|---|---|
textareaRef | RefObject<HTMLTextAreaElement | null> | - | When provided, transcribed text is appended to the textarea value. |
onTranscriptionChange | (text: string) => void | - | Fires whenever a final transcript is appended. |
lang | string | "en-US" | BCP-47 language tag passed to SpeechRecognition. |
Carries aria-label="Toggle voice input". Disabled when neither SpeechRecognition nor webkitSpeechRecognition is available. Animates a pulsing accent surface while listening.
<PromptInputSelect> family
Wrappers around Select from @gremorie/rx-forms (PromptInputSelect, PromptInputSelectTrigger, PromptInputSelectContent, PromptInputSelectItem, PromptInputSelectValue). The trigger is restyled to look inline inside the input row (borderless, transparent background, muted foreground).
<PromptInputHoverCard> family
PromptInputHoverCard, PromptInputHoverCardTrigger, PromptInputHoverCardContent. HoverCard from @gremorie/rx-overlays with openDelay and closeDelay defaulted to 0 so previews feel instant. Content defaults to align="start". Used internally by PromptInputAttachment.
<PromptInputCommand> family
PromptInputCommand, PromptInputCommandInput, PromptInputCommandList, PromptInputCommandEmpty, PromptInputCommandGroup, PromptInputCommandItem, PromptInputCommandSeparator. Thin wrappers around the Command palette primitives.
PromptInputCommandDialog is the Cmd+K floating palette - same Command palette inside a Dialog for global launcher use.
<PromptInputTabs*> family
PromptInputTabsList, PromptInputTab, PromptInputTabLabel, PromptInputTabBody, PromptInputTabItem. Lightweight non-stateful tabbed panels for use inside menus and dropdowns. They are styled containers - bring your own selection state.
Hooks
| Hook | Purpose |
|---|---|
usePromptInputController() | Read or mutate the text input value and attachments. Throws if no PromptInputProvider parent. |
useProviderAttachments() | Read attachment state lifted to the provider. Throws if no PromptInputProvider parent. |
usePromptInputAttachments() | Read attachment state from the closest PromptInput. Used inside descendants like PromptInputTextarea and PromptInputAttachment. |
Composition
PromptInput (root <form>, state machine + drag-drop + InputGroup wrapper)
PromptInputBody (display: contents, flows children into the group)
PromptInputHeader (optional, above the textarea)
PromptInputAttachments (render-prop list of chips, hidden when empty)
PromptInputAttachment (chip + HoverCard preview + remove button)
PromptInputTextarea (auto-resize, Enter submits, Shift+Enter newline)
PromptInputFooter (default home for tools + submit)
PromptInputTools (left, action buttons)
PromptInputActionMenu (dropdown trigger + content + items)
PromptInputActionAddAttachments (pre-wired item)
PromptInputSpeechButton (Web Speech toggle)
PromptInputButton (custom actions - web search, etc.)
PromptInputSelect (model picker)
PromptInputSubmit (right, status-driven icon)How the parts plug together
- InputGroup wrapping: The React root wraps
<form>content in anInputGroupfrom@gremorie/rx-forms. That gives you a single rounded surface with shared focus, invalid and disabled states across the textarea and every addon. - Block-aligned addons:
PromptInputHeaderandPromptInputFooterare bothInputGroupAddon align="block-end". They render below the textarea visually but participate in the group's focus ring. - Sizing: Every toolbar button picks
xs,sm,icon-xsoricon-smto match the group height automatically.PromptInputButtonpicksicon-smwhen it has a single child andsmwhen it has icon+label, unless you override. - Attachment state context:
PromptInput(orPromptInputProviderwhen present) provides the attachments context thatPromptInputAttachments,PromptInputAttachmentandPromptInputActionAddAttachmentsall read. Adding via the action menu, paste, drag-drop orattachments.add(files)all funnel into the same list.
Variations
Lifted state via Provider
Use PromptInputProvider to share the textarea value and attachments with siblings (e.g. a suggestions panel that wants to write into the input).
import { PromptInputProvider, usePromptInputController } from '@gremorie/rx-ai';
function SuggestionRow() {
const { textInput } = usePromptInputController();
return (
<Button onClick={() => textInput.setInput('Summarize this thread')}>
Insert suggestion
</Button>
);
}
export function ChatSurface() {
return (
<PromptInputProvider initialInput="">
<SuggestionRow />
<PromptInput onSubmit={handle}>
<PromptInputBody>
<PromptInputTextarea />
<PromptInputFooter>
<span />
<PromptInputSubmit />
</PromptInputFooter>
</PromptInputBody>
</PromptInput>
</PromptInputProvider>
);
}Constrained image attachments
Reject everything except images and cap at 5 MB.
<PromptInput
accept="image/*"
multiple
maxFiles={4}
maxFileSize={5_000_000}
onError={(err) => toast.error(err.message)}
onSubmit={handle}
>
{/* ... */}
</PromptInput>Global drop zone
Accept files dropped anywhere on the page.
<PromptInput globalDrop accept="image/*" onSubmit={handle}>
{/* ... */}
</PromptInput>With Cmd+K command palette
Pair the inline composer with a global launcher using PromptInputCommandDialog.
const [open, setOpen] = useState(false);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((v) => !v);
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, []);
<PromptInputCommandDialog open={open} onOpenChange={setOpen}>
<PromptInputCommandInput placeholder="Type a command..." />
<PromptInputCommandList>
<PromptInputCommandEmpty>No results.</PromptInputCommandEmpty>
<PromptInputCommandGroup heading="Actions">
<PromptInputCommandItem>New chat</PromptInputCommandItem>
<PromptInputCommandItem>Clear history</PromptInputCommandItem>
</PromptInputCommandGroup>
</PromptInputCommandList>
</PromptInputCommandDialog>;Streaming status
Wire status to your network state to flip Submit between arrow, spinner and stop.
const [status, setStatus] = useState<ChatStatus>('ready');
async function send(message: PromptInputMessage) {
setStatus('submitted');
try {
await streamResponse(message, {
onStreamStart: () => setStatus('streaming'),
onComplete: () => setStatus('ready'),
});
} catch {
setStatus('error');
}
}
<PromptInputSubmit
status={status}
onClick={status === 'streaming' ? stop : undefined}
/>;Accessibility
- Real
<form>semantics:PromptInputrenders a<form>, so Enter submits viaform.requestSubmit(). HTML form validation still runs - nativerequired/patternattributes work on any field inside. - Submit
aria-label:PromptInputSubmitalways carriesaria-label="Submit". If your localization differs, override via your ownButtoncomposition. - Keyboard contract (documented above and enforced in code): Enter submits, Shift+Enter inserts a newline, Backspace on empty textarea removes the latest attachment. The IME composition flag (
onCompositionStart/onCompositionEnd) suppresses Enter while composing CJK input. - Speech button degradation: When the Web Speech API is missing,
PromptInputSpeechButtonis disabled rather than hidden.aria-label="Toggle voice input"stays on the disabled button so screen readers can describe what is unavailable. - Attachment remove: Each chip's remove button carries
aria-label="Remove attachment"plus ansr-only"Remove" span. - Tools needing tooltips: Icon-only
PromptInputButtons require anaria-label. There is no built-in tooltip, so wrap each inTooltipfrom@gremorie/rx-overlayswhen you want a hover hint. - Status announcement: When
statuschanges, consider announcing the new state via a separatearia-liveregion in the host app - Submit's icon swap alone is silent for screen readers. - Focus visible: The
InputGroupsurface drives the focus ring. Do not strip outlines. - Reduced motion: The speech button's pulse animation and the submit spinner respect
prefers-reduced-motionvia Tailwind's defaultmotion-reduce:variants (apply manually to custom additions).
Related
- Message - the assistant / user turn rendered above this composer
- Conversation - the scrollable surface that hosts both messages and this composer
- Model Selector - alternative standalone model picker if you do not want it inline
- Suggestion - pre-baked prompts users can click to seed the textarea
- Open in Chat - hand the current query off to an external chat