PromptInput
A composer for AI chat and standalone "Ask anything" boxes — auto-growing textarea, 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: pressing its dead space — the header and footer padding, or the
gap between their controls — focuses the input, while anything you put in those
slots keeps its own press. (The slots sit outside hit testing, so those presses
land on the form, which does the focusing.) PromptInput.Textarea registers
itself as that input on mount; pass inputRef when you render a custom input
instead. A consumer onMouseDown that calls preventDefault() opts out.
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
Header
A slot row above the textarea, 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
input is empty, shows a spinner while status="submitted", and flips to a
stop square that calls onStop while status="streaming".
Prop
Type
Examples
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 input 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>
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. - The composer frame carries the focus treatment — the border tints when a
child has visible focus (
:has(:focus-visible)) — while the borderless textarea 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.