Prompt Input
A message field for chat and AI interfaces that sends on Enter and turns Send into Stop while a reply runs.
Northlight AI can make mistakes. Check important information.
Installation
pnpm dlx shadcn@4.18.0 add @siteplane/prompt-inputComplete the one-time setup first. Registry JSON lists source files and dependencies.
For this page’s examples, also install:
pnpm dlx shadcn@4.18.0 add @siteplane/attachment @siteplane/empty-state @siteplane/file-type-icon @siteplane/menuUsage
import {
PromptInput,
PromptInputSubmit,
PromptInputTextarea,
PromptInputToolbar,
PromptInputToolbarGroup,
} from "@/siteplane/components/prompt-input";
<PromptInput onSubmit={({ text }) => console.log(text)}>
<PromptInputTextarea placeholder="Ask anything" />
<PromptInputToolbar>
<PromptInputToolbarGroup align="end">
<PromptInputSubmit />
</PromptInputToolbarGroup>
</PromptInputToolbar>
</PromptInput>;onSubmit receives the draft without the white space around it. The field then clears itself and keeps focus, so the next message can be typed at once. The app owns the messages, the files, the model and the transport.
Composition
PromptInput a form around an Input Group
├── PromptInputAttachments optional row above the field
│ └── AttachmentGroup
│ └── Attachment
├── PromptInputTextarea the field
└── PromptInputToolbar the row under the field
├── PromptInputToolbarGroup the controls that start the row
│ └── PromptInputAttachButton
└── PromptInputToolbarGroup align="end", the controls that end it
└── PromptInputSubmitSending and new lines
Enter sends the draft and Shift+Enter adds a line. An Enter that confirms a candidate of an input method editor, as in Chinese, Japanese or Korean text, sends nothing. Send is disabled while the draft is empty or holds only white space.
submitOnEnter decides which Enter sends. always is the default. desktop sends on Enter only with a fine pointer, a mouse or a trackpad, so the Enter of a phone keyboard adds a line and the Send button sends. never leaves Enter to the field, and only the button sends.
<PromptInput onSubmit={send} submitOnEnter="desktop">
…
</PromptInput>The draft is uncontrolled by default, and defaultValue sets its first text. Pass value and onValueChange to control it; a controlled draft is the app’s to clear after a send, as in the empty state example. The field grows from one line to eight with its draft and then scrolls. Set minLines and maxLines on PromptInputTextarea for other limits.
Stop a running reply
status takes the words of the AI SDK’s chat status. ready waits for a message. submitted has sent one and waits for the reply to start, and streaming receives it. During both, Send turns into Stop, which calls onStop, and no message is sent until the status changes again. error shows Send again, so the message can be sent once more.
The field stays editable while a reply runs, so the next message can be written meanwhile. Enter then neither sends nor adds a line, and Shift+Enter still adds one. Send and Stop are one round Button whose icon fades from the arrow to the square. It never shows a loading spinner, because a loading Button is disabled and Stop has to stay pressable.
Send a message in the first example on this page to see it: Send turns into Stop for a few seconds, and Stop ends the reply at once. The examples on this page stand in for the model and the uploads with timers.
Attachments
Put an AttachmentGroup with small cards (size="sm") in PromptInputAttachments, the row above the field, and render the row only while the draft holds files. PromptInputAttachButton opens the file picker and hands the picked files to onFilesSelected. Pass accept to limit the file types and multiple={false} for a single file. The app keeps the files, uploads them and gives each card its state. onSubmit receives the text only, so send the files from your own state with it.
A draft with files and no text is held back unless allowEmpty is set. Set it while the draft holds a file that can go, as this example does once a file is uploaded. A failed upload offers Retry. In this example Send also waits until every file is uploaded, through the disabled prop of PromptInputSubmit. The states of a card are on the Attachment page.
A card is inert while it leaves the row, so its button cannot keep focus. Move focus to the field before you remove a card or retry an upload, as the examples on this page do.
Model and effort
A model or effort picker is a Menu in the toolbar. A ghost Button shows the current choice and opens a radio group. Put the pickers in the end group, before Send. The app keeps each choice and sends it with the message, for example in the body of the request. A menu is not a form field, so the form submits nothing of it.
Empty state and suggestions
A new chat shows an Empty State with a short title and description in the middle of the thread area, and suggestions above the field. Suggestions are outline pill Buttons (variant="outline", shape="pill" and size="sm") in a row that wraps, in the column of the field and 16 px above it. A press can send the prompt at once or, as here, put it in the draft and move focus to the field, so it can be changed before it goes.
Surfaces
variant sets the surface of the field to default, elevated, inset or soft, the surfaces of Input Group. The examples on this page use the default.
Labels
The parts name themselves for assistive technology. The field is “Message”, the submit button “Send message” or “Stop”, and the attach button “Attach files”, which its tooltip shows as well. Replace any of these words through labels, for example to say what the field is for.
<PromptInput
labels={{ send: "Send reply", textarea: "Reply to Ana" }}
onSubmit={send}
>
…
</PromptInput>Name the other parts yourself: the row of files with label on AttachmentGroup, each card action with an aria-label that names its file, such as “Remove contracts.pdf”, and a picker’s trigger with an aria-label that says what it picks, such as “Model: Thorough”.
AI SDK
The field uses the AI SDK’s words for the chat status, so it connects to useChat without a mapping of its own. The examples on this page run on local state; the code below connects the field to useChat instead.
"use client";
import { useChat } from "@ai-sdk/react";
import {
PromptInput,
PromptInputSubmit,
PromptInputTextarea,
PromptInputToolbar,
PromptInputToolbarGroup,
} from "@/siteplane/components/prompt-input";
export function Chat({ model }: { model: string }) {
const { messages, sendMessage, status, stop } = useChat();
return (
<>
{/* The thread renders messages here. */}
<PromptInput
onStop={stop}
onSubmit={({ text }) => sendMessage({ text }, { body: { model } })}
status={status}
>
<PromptInputTextarea placeholder="Ask anything" />
<PromptInputToolbar>
<PromptInputToolbarGroup align="end">
<PromptInputSubmit />
</PromptInputToolbarGroup>
</PromptInputToolbar>
</PromptInput>
</>
);
}Status
status from useChat passes straight through. ready shows Send. submitted and streaming show Stop, and Stop calls stop, which ends the stream. After error the field shows Send again, while the thread says what failed and regenerate from useChat asks for the reply again.
Model and effort
The choices of the pickers go with each message in the body of the request, as model does above, and your route reads them there.
Files
Files go with a message as FileUIParts. Turn each picked file into one, with a data URL as below or the URL of your own upload, and show it as an Attachment card while it is in the draft. A file that uploads shows as uploading until its URL is there. A draft of files without text sends them as the parts of the message, and allowEmpty lets it go. The Chat above, with files:
"use client";
import { useChat } from "@ai-sdk/react";
import type { FileUIPart } from "ai";
import { XIcon } from "lucide-react";
import { useRef, useState } from "react";
import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
} from "@/siteplane/components/attachment";
import { FileTypeIcon } from "@/siteplane/components/file-type-icon";
import {
PromptInput,
PromptInputAttachButton,
PromptInputAttachments,
PromptInputSubmit,
PromptInputTextarea,
PromptInputToolbar,
PromptInputToolbarGroup,
} from "@/siteplane/components/prompt-input";
// A data URL, as here, or the URL of your own upload for large files.
function toFilePart(file: File): Promise<FileUIPart> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () =>
resolve({
filename: file.name,
mediaType: file.type,
type: "file",
url: String(reader.result),
});
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
export function Chat({ model }: { model: string }) {
const { messages, sendMessage, status, stop } = useChat();
const [files, setFiles] = useState<{ id: string; part: FileUIPart }[]>([]);
const field = useRef<HTMLTextAreaElement>(null);
const parts = files.map((file) => file.part);
return (
<>
{/* The thread renders messages here. */}
<PromptInput
allowEmpty={files.length > 0}
onStop={stop}
onSubmit={({ text }) => {
sendMessage(text ? { text, files: parts } : { parts }, {
body: { model },
});
setFiles([]);
}}
status={status}
>
{files.length > 0 ? (
<PromptInputAttachments>
<AttachmentGroup label="Attachments">
{files.map(({ id, part }) => (
<Attachment key={id} size="sm">
<AttachmentMedia>
<FileTypeIcon name={part.filename ?? "file"} size={24} />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{part.filename}</AttachmentTitle>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
aria-label={`Remove ${part.filename}`}
onClick={() => {
field.current?.focus();
setFiles((current) =>
current.filter((file) => file.id !== id),
);
}}
>
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
))}
</AttachmentGroup>
</PromptInputAttachments>
) : null}
<PromptInputTextarea placeholder="Ask anything" ref={field} />
<PromptInputToolbar>
<PromptInputToolbarGroup>
<PromptInputAttachButton
onFilesSelected={async (picked) => {
const added = await Promise.all(picked.map(toFilePart));
setFiles((current) => [
...current,
...added.map((part) => ({ id: crypto.randomUUID(), part })),
]);
}}
/>
</PromptInputToolbarGroup>
<PromptInputToolbarGroup align="end">
<PromptInputSubmit />
</PromptInputToolbarGroup>
</PromptInputToolbar>
</PromptInput>
</>
);
}The file parts of a sent message become Attachment cards as well, as the Attachment page shows.
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
Prompt | (message: { text: string }) => void | required | Called when Enter or Send sends the draft, with its text trimmed. Never called while a reply runs, while the field is disabled, or for a draft without text unless allowEmpty is set. |
Prompt | ready | submitted | streaming | error | ready | Where the chat is in a turn, in the words of the AI SDK. submitted and streaming turn Send into Stop. |
Prompt | () => void | unset | Called when Stop is pressed. |
Prompt | string | unset | The draft of a controlled field. The app clears it after a send. |
Prompt | string | "" | The first draft of an uncontrolled field, which clears itself after a send. |
Prompt | (value: string) => void | unset | Called with the draft while it is typed. |
Prompt | always | desktop | never | always | Which Enter sends. desktop sends only with a fine pointer, and never leaves Enter to the field. Shift+Enter always adds a line, and an Enter that ends an IME composition never sends. |
Prompt | boolean | false | Lets a draft without text go, for a message of attachments alone. |
Prompt | boolean | false | Turns off the field, the attach button and Send. Stop stays pressable, so a reply that runs can always be stopped. |
Prompt | default | elevated | inset | soft | default | The surface of the field, from Input Group. |
Prompt | Partial<PromptInputLabels> | English | Replaces the words: textarea (Message), send (Send message), stop (Stop) and attach (Attach files, also its tooltip). |
Prompt | number | 1 / 8 | The least and the most height in lines. The field grows between them with its draft, then scrolls. |
Prompt | start | end | start | end puts the group at the end of the toolbar, also when there is no start group or the row wraps. |
Prompt | (files: File[]) => void | required | Called with the picked files. The same file can be picked again. |
Prompt | string | unset | The file types the picker offers, as the accept attribute of a file input takes them. |
Prompt | boolean | true | Whether the picker takes several files. |
Prompt | Button props | round accent icon Button | Send, or Stop while a reply runs. variant, shape or size replace the default look. Its own disabled turns it off, Stop included, so set it only while no reply runs. |
PromptInput renders the kit Form with an Input Group
inside, and accepts the native props of a form except onSubmit and
defaultValue. Enter and Send both submit it, and it never navigates.
PromptInputAttachments and PromptInputToolbar are Input Group addons at
block-start and block-end, and accept the addon props except align.
PromptInputTextarea accepts the props of InputGroupTextarea except value and
defaultValue, since the draft belongs to PromptInput, and passes its ref to
the textarea. PromptInputAttachButton accepts Button props except type and
onChange; its children replace the Paperclip icon, and the picker is a hidden
kit Input of type file. PromptInputSubmit accepts Button props except children
and type. PromptInputTextarea, PromptInputAttachButton and PromptInputSubmit
read the PromptInput around them and throw outside one. The types
PromptInputProps, PromptInputLabels, PromptInputStatus,
PromptInputSubmitOnEnter, PromptInputTextareaProps,
PromptInputToolbarGroupProps, PromptInputAttachButtonProps and
PromptInputSubmitProps are exported.
Usage notes
The field sends nothing by itself. onSubmit hands over the text, and the app adds the message to its thread, sends it with its files and settings, and sets status while the reply runs.
Always keep a name on the field; the placeholder is not a label. The field grows with its draft where the browser supports field-sizing: content, as Textarea does, and keeps its starting height elsewhere.
Under reduced motion the icon of the submit button changes at once and the names of uploading files stand still.