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);
4
5 React.useEffect(() => () => clearTimeout(timerRef.current), []);
6
7 return (
8 <div style={{ width: 420 }}>
9 <PromptInput
10 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'
2
3<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 Skills
9 </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

A slot row above the textarea, usually for Chat.Attachment previews. Hidden while empty.

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.Attachment
5 title="design-spec.pdf"
6 description="1.2 MB"
7 onRemove={() => {}}
8 />
9 <Chat.Attachment
10 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 calls onStop.
  • streaming — tokens are arriving; the button flips to a stop square that calls onStop, 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"];
3
4 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 <PromptInput
12 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("");
3
4 return (
5 <Flex direction="column" gap={4} style={{ width: 420 }}>
6 <PromptInput
7 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 <Button
6 type="button"
7 variant="ghost"
8 color="neutral"
9 size="small"
10 disabled
11 >
12 Skills
13 </Button>
14 <PromptInput.Submit />
15 </PromptInput.Footer>

Accessibility

  • The root is a native <form>; PromptInput.Submit is 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 mousedown and 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.