PromptInput
A composer for AI chat and standalone "Ask anything" boxes — auto-growing input, inline @-mention chips, toolbar slots, and a status-aware submit button.1(function PromptInputPreview() {2 const [status, setStatus] = React.useState("idle");3 const timerRef = React.useRef(null);45 React.useEffect(() => () => clearTimeout(timerRef.current), []);67 return (8 <div style={{ width: 420 }}>9 <PromptInput10 status={status}11 onSubmit={(value, event) => {12 event.currentTarget.reset();13 setStatus("streaming");14 timerRef.current = setTimeout(() => setStatus("idle"), 2500);15 }}
Anatomy
Import and assemble the composer. The root is a form that owns submit semantics: Enter submits, Shift+Enter inserts a newline, and IME composition is respected.
1import { PromptInput } from '@raystack/apsara'23<PromptInput onSubmit={send} onStop={stop} status={status}>4 <PromptInput.Header>{/* attachment previews */}</PromptInput.Header>5 <PromptInput.Textarea placeholder="Reply…" />6 <PromptInput.Footer>7 <Button type="button" variant="ghost" color="neutral" size="small">8 Skills9 </Button>10 <PromptInput.Submit />11 </PromptInput.Footer>12</PromptInput>
PromptInput is purely presentational: it never talks to a model or manages a
request. Pass the lifecycle in through status and react to onSubmit /
onStop — it works with the AI SDK's useChat, a custom SSE client, or
anything else.
API Reference
Root
The <form> that holds the value and submit semantics. The whole frame reads
as one field: clicking its empty space — the header and footer padding, or the
gap between their controls — focuses the input, while anything you put in those
slots still handles its own clicks. Whichever input part you render registers
itself on mount; pass inputRef if you render your own input instead. An
onMouseDown that calls preventDefault() opts out.
Prop
Type
onSubmit gives you a message object rather than a plain string, so mentions
arrive with their ids and your own data next to the text:
Prop
Type
Prop
Type
Textarea
The text field, built on TextArea variant="borderless". It grows with its
content up to a max-height cap (override with
--prompt-input-textarea-max-height), then scrolls.
Prop
Type
Editor
The input to use when you want @-mention chips. Use it instead of
Textarea, not alongside it.
It behaves like Textarea — Enter submits, Shift+Enter adds a newline, same
placeholder and same growth — and adds undo/redo plus the mention menu.
Its line height is taller than Textarea's, so a chip fits inside one line
without changing the composer's height as chips come and go. A single-row
Editor is a few pixels taller than a single-row Textarea.
A few Textarea props work differently here. readOnly becomes disabled,
onChange becomes onValueChange on the root, rows becomes CSS
min-height, and its ref is an HTMLDivElement.
Prop
Type
Mentions
Sets up a trigger character and its data. It renders nothing on its own and
needs PromptInput.Editor — next to a Textarea it does nothing and warns in
development.
Pass items for a list you already have, or onSearch to fetch results as the
user types. onSearch wins if you pass both.
Prop
Type
Prop
Type
Each item sets its own type, so one @ menu can mix kinds — the demo below
has a page, components and users. You get type back on submit.
Header
A slot row above the input, usually for Chat.Attachment previews. Hidden
while empty.
Footer
The bottom toolbar row. Hidden while empty. Toolbar controls — attach, skills,
model pickers and similar — are composed from regular Apsara components
(Button, IconButton, Select, …); give them type="button" so they don't
submit the form.
Submit
The trailing send button. It renders ↑ when idle, disables itself while the
composer is empty, shows a spinner while status="submitted", and flips to a
stop square that calls onStop while status="streaming".
Prop
Type
Value format
A chip is written as @[label](type:id), using whatever character the trigger
is:
1check @[DataTable](component:data-table) with @[Maya Chen](user:u1)
value, defaultValue and the first argument of onValueChange all use this
format, so the obvious controlled wiring keeps chips intact:
1const [value, setValue] = useState('');2<PromptInput value={value} onValueChange={setValue} />
Save this string as the user's draft. A few details:
],)and\in a label are escaped with\, as are:,)and\in a type or id.- A line break is written as
\n. - Anything that does not parse stays as plain text — nothing throws, nothing is dropped.
- Only
valueanddefaultValueare parsed. Text you type or paste that happens to look like@[…](…)stays as-is. - Icons and
datacannot be saved in a string, so restoring a draft needsresolveMentions— see Restoring a draft.
Examples
Everything the Editor can do
One composer with all of it turned on: a restored draft, a mention menu with icons, a badge, a disabled row and sections, a length cap, buttons that drive it from code, and a live readout of what you'd get on submit.
1(function EditorCapabilities() {2 const composer = React.useRef(null);34 // Ungrouped items lead the menu; groups follow in the order they first5 // appear. Icons, trailing badges, disabled rows and opaque `data` all work.6 const ITEMS = [7 {8 id: "page",9 label: "This page",10 type: "page",11 icon: <FileIcon />,12 data: { path: "/docs/ai-elements/prompt-input" },13 },14 {15 id: "button",
Things worth trying in it:
- Chips behave like one character. Backspace deletes a whole chip, arrow keys step past it, and clicking one selects it.
- Undo and redo with Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z. Inserting a chip undoes in one step.
- Copy a chip and paste it back — it comes back as a chip, id intact.
Paste it into a plain text field and you get
@Maya Chen. - Paste formatted text from a doc or a webpage and it arrives as plain text. The editor has no bold, headings or lists to paste into.
- Shift+Enter adds a line break; Enter sends.
- The length cap counts a chip as its label and applies to pasted text too, so you can't paste past it.
- Multi-word search: type
@maya ch— the space keeps searching. Keep typing past the point where nothing matches and the menu gives up and leaves your text alone. - Escape closes the menu and leaves what you typed as plain text. Delete a character and it comes back.
Mentions
Typing @ opens a menu at the cursor. Picking an item drops a chip into the
text, followed by a space. The chip deletes in one press, arrow keys step over
it, and a message that is nothing but a chip still sends.
The chip itself shows the item's icon and label — not the @. The trigger is
kept in the text you get on submit (@Maya Chen), where the mention boundary
has to survive being read by a model.
Sections appear in the order their group first shows up in your data, so you
control the order by ordering items. Items without a group come first, and
a section with no matches disappears.
1(function MentionsPromptInput() {2 const ITEMS = [3 { id: "button", label: "Button", type: "component", group: "Components" },4 {5 id: "data-table",6 label: "DataTable",7 type: "component",8 group: "Components",9 },10 {11 id: "prompt-input",12 label: "PromptInput",13 type: "component",14 group: "Components",15 },
Async mentions and restoring a draft
onSearch runs about 150 ms after the user stops typing and gets an
AbortSignal you can pass to fetch. Out-of-date responses are ignored,
loading rows show while a request is out, and a failed request shows the empty
state.
A query can contain spaces so multi-word names stay searchable. Once a query with a space stops matching anything, the menu closes and the text is left alone.
Restoring a draft
A chip loaded from defaultValue starts with just its label, since a saved
string can't carry an icon or data. Pass resolveMentions to look those up
and fill them in — it also refreshes the label if the entity was renamed.
Lookups are batched and cached. If one fails or comes back empty, the chip
simply stays label-only; it is never removed.
1(function AsyncMentionsPromptInput() {2 const DIRECTORY = [3 { id: "button", label: "Button", type: "component", group: "Components" },4 {5 id: "data-table",6 label: "DataTable",7 type: "component",8 group: "Components",9 },10 {11 id: "prompt-input",12 label: "PromptInput",13 type: "component",14 group: "Components",15 },
Inserting a mention from code
actionsRef gives you focus, clear, getValue and insertMention.
insertMention inserts at the cursor, or at the end if the editor isn't
focused. ref still points at the <form>.
1(function InsertMentionPromptInput() {2 const composer = React.useRef(null);34 // No <PromptInput.Mentions> here: typing @ does nothing, but chips added5 // from code still render with their icon.6 return (7 <div style={{ width: 420 }}>8 <PromptInput9 actionsRef={composer}10 onSubmit={(message, event) => event.currentTarget.reset()}11 >12 <PromptInput.Editor placeholder="Write a message…" />13 <PromptInput.Footer>14 <Button15 type="button"
Attachments in the header
Attachment previews are presentational (Chat.Attachment); file picking,
drag-drop and uploads belong to your app.
1<div style={{ width: 420 }}>2 <PromptInput onSubmit={(value) => console.log(value)}>3 <PromptInput.Header>4 <Chat.Attachment5 title="design-spec.pdf"6 description="1.2 MB"7 onRemove={() => {}}8 />9 <Chat.Attachment10 title="screenshot.png"11 state="uploading"12 description="Uploading…"13 />14 </PromptInput.Header>15 <PromptInput.Textarea placeholder="Reply…" />
Status
status reflects the request lifecycle your app manages:
idle— the resting state; the submit button shows the send arrow and disables itself while the composer is empty.submitted— the request is sent but nothing has streamed back yet; the button shows a spinner, Enter/submit is blocked, and clicking callsonStop.streaming— tokens are arriving; the button flips to a stop square that callsonStop, and submit stays blocked.error— the request failed; the composer returns to a submittable state (send arrow) so the user can retry. Render your error message outside the composer.
1(function PromptInputStatuses() {2 const STATUSES = ["idle", "submitted", "streaming", "error"];34 return (5 <Flex gap={5} wrap="wrap">6 {STATUSES.map((status) => (7 <Flex key={status} direction="column" gap={2} style={{ width: 320 }}>8 <Text size="small" variant="secondary">9 status="{status}"10 </Text>11 <PromptInput12 status={status}13 defaultValue="Summarize this thread"14 onSubmit={() => {}}15 onStop={() => {}}
Controlled value
Control value to sync the draft elsewhere — persistence, slash-command
menus, mention pickers.
1(function ControlledPromptInput() {2 const [value, setValue] = React.useState("");34 return (5 <Flex direction="column" gap={4} style={{ width: 420 }}>6 <PromptInput7 value={value}8 onValueChange={setValue}9 onSubmit={() => setValue("")}10 >11 <PromptInput.Textarea placeholder="Write a message…" />12 <PromptInput.Footer>13 <PromptInput.Submit />14 </PromptInput.Footer>15 </PromptInput>
Disabled
1<div style={{ width: 420 }}>2 <PromptInput disabled>3 <PromptInput.Textarea placeholder="Read-only conversation" />4 <PromptInput.Footer>5 <Button6 type="button"7 variant="ghost"8 color="neutral"9 size="small"10 disabled11 >12 Skills13 </Button>14 <PromptInput.Submit />15 </PromptInput.Footer>
When is the composer empty?
A composer counts as empty when it has no chips and its text is only
whitespace. The submit button, the data-empty attribute and the placeholder
all use that same rule, so they always agree:
- A message that is only a chip is not empty and sends.
- Whitespace on its own is empty.
- Leading and trailing whitespace is trimmed off the submitted message, including the space added after a chip. Chips are never trimmed.
Accessibility
- The root is a native
<form>;PromptInput.Submitis a real submit button, so assistive tech announces the submit affordance for free. - Enter submits and Shift+Enter inserts a newline. During IME composition Enter confirms the composition instead of submitting.
- While a response is in flight the submit button becomes
type="button"with an updated accessible name ("Stop response") so it can never resubmit the form. Editorand its mention menu use the standard combobox roles, so screen readers announce the highlighted row as the user arrows through it. Focus stays in the editor the whole time — the menu never takes it.- While the menu is open, ↑ ↓ Enter Tab and Escape control the menu. Escape
closes the menu without also closing a surrounding
ChatPanel,DialogorDrawer, so the draft survives. With the menu closed, those keys behave as usual. - Each chip has an accessible name, like
"mention: DataTable". - The composer frame carries the focus treatment — the border tints when a
child has visible focus (
:has(:focus-visible)) — while the borderless input suppresses its own duplicate focus ring. - Click-to-focus runs on
mousedownand cancels the default focus shift, so focus never leaves the input and back again — a round trip that would dismiss anything anchored to it. Keyboard focus order is untouched.