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);
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: 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.

A slot row above the input, 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 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 value and defaultValue are parsed. Text you type or paste that happens to look like @[…](…) stays as-is.
  • Icons and data cannot be saved in a string, so restoring a draft needs resolveMentions — 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);
3
4 // Ungrouped items lead the menu; groups follow in the order they first
5 // 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);
3
4 // No <PromptInput.Mentions> here: typing @ does nothing, but chips added
5 // from code still render with their icon.
6 return (
7 <div style={{ width: 420 }}>
8 <PromptInput
9 actionsRef={composer}
10 onSubmit={(message, event) => event.currentTarget.reset()}
11 >
12 <PromptInput.Editor placeholder="Write a message…" />
13 <PromptInput.Footer>
14 <Button
15 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.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 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 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>

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.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.
  • Editor and 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, Dialog or Drawer, 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 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.