# Prompt Input

A message field for chat and AI interfaces that sends on Enter and turns Send into Stop while a reply runs.

Message field with an attach button and a model picker

[Complete TSX file](/examples/prompt-input/default.tsx)

```tsx
"use client";

import { ChevronDownIcon, XIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentGroup,
  AttachmentMedia,
  AttachmentTitle,
} from "@/siteplane/components/attachment";
import { Button } from "@/siteplane/components/button";
import { FileTypeIcon } from "@/siteplane/components/file-type-icon";
import {
  Menu,
  MenuGroup,
  MenuGroupLabel,
  MenuPopup,
  MenuRadioGroup,
  MenuRadioItem,
  MenuTrigger,
} from "@/siteplane/components/menu";
import {
  PromptInput,
  PromptInputAttachButton,
  PromptInputAttachments,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputToolbar,
  PromptInputToolbarGroup,
  type PromptInputStatus,
} from "@/siteplane/components/prompt-input";

const models = ["Fast", "Thorough"];

type DraftFile = { id: string; name: string; size: string };

function formatSize(bytes: number) {
  return bytes < 1024 * 1024
    ? `${Math.max(1, Math.round(bytes / 1024))} KB`
    : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

export default function Example() {
  const [files, setFiles] = useState<DraftFile[]>([]);
  const [model, setModel] = useState("Thorough");
  const [status, setStatus] = useState<PromptInputStatus>("ready");
  const field = useRef<HTMLTextAreaElement>(null);

  // Stand-in for a reply: it is submitted, streams for a few seconds and ends.
  useEffect(() => {
    if (status !== "submitted" && status !== "streaming") return;
    const timer = setTimeout(
      () => setStatus(status === "submitted" ? "streaming" : "ready"),
      status === "submitted" ? 800 : 3200,
    );
    return () => clearTimeout(timer);
  }, [status]);

  return (
    <div className="flex w-full max-w-lg flex-col gap-2">
      <PromptInput
        allowEmpty={files.length > 0}
        onStop={() => setStatus("ready")}
        onSubmit={() => {
          setFiles([]);
          setStatus("submitted");
        }}
        status={status}
      >
        {files.length > 0 ? (
          <PromptInputAttachments>
            <AttachmentGroup label="Attachments">
              {files.map((file) => (
                <Attachment key={file.id} size="sm">
                  <AttachmentMedia>
                    <FileTypeIcon name={file.name} size={24} />
                  </AttachmentMedia>
                  <AttachmentContent>
                    <AttachmentTitle>{file.name}</AttachmentTitle>
                    <AttachmentDescription>{file.size}</AttachmentDescription>
                  </AttachmentContent>
                  <AttachmentActions>
                    <AttachmentAction
                      aria-label={`Remove ${file.name}`}
                      onClick={() => {
                        // A card is inert while it leaves, so focus moves to
                        // the field first.
                        field.current?.focus();
                        setFiles((current) =>
                          current.filter((entry) => entry.id !== file.id),
                        );
                      }}
                    >
                      <XIcon />
                    </AttachmentAction>
                  </AttachmentActions>
                </Attachment>
              ))}
            </AttachmentGroup>
          </PromptInputAttachments>
        ) : null}
        <PromptInputTextarea
          placeholder="Ask anything about your workspace"
          ref={field}
        />
        <PromptInputToolbar>
          <PromptInputToolbarGroup>
            <PromptInputAttachButton
              onFilesSelected={(picked) =>
                setFiles((current) => [
                  ...current,
                  ...picked.map((file) => ({
                    id: crypto.randomUUID(),
                    name: file.name,
                    size: formatSize(file.size),
                  })),
                ])
              }
            />
          </PromptInputToolbarGroup>
          <PromptInputToolbarGroup align="end">
            <Menu>
              <MenuTrigger
                render={
                  <Button
                    aria-label={`Model: ${model}`}
                    size="sm"
                    variant="ghost"
                  />
                }
              >
                {model}
                <ChevronDownIcon />
              </MenuTrigger>
              <MenuPopup align="end" side="top">
                <MenuGroup>
                  <MenuGroupLabel>Model</MenuGroupLabel>
                  <MenuRadioGroup onValueChange={setModel} value={model}>
                    {models.map((name) => (
                      <MenuRadioItem closeOnClick key={name} value={name}>
                        {name}
                      </MenuRadioItem>
                    ))}
                  </MenuRadioGroup>
                </MenuGroup>
              </MenuPopup>
            </Menu>
            <PromptInputSubmit />
          </PromptInputToolbarGroup>
        </PromptInputToolbar>
      </PromptInput>
      <p className="text-center text-muted-foreground text-xs">
        Northlight AI can make mistakes. Check important information.
      </p>
    </div>
  );
}
```

## Installation

Complete [one-time setup](/docs/installation/) first.

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/prompt-input
```

With npm: `npx shadcn@4.18.0 add @siteplane/prompt-input`. [Registry JSON](/registry/prompt-input.json) lists source files and dependencies.

For this page’s examples, also install:

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/attachment @siteplane/empty-state @siteplane/file-type-icon @siteplane/menu
```

## Usage

```tsx
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

```text
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
        └── PromptInputSubmit
```

## Sending 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.

```tsx
<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](#empty-state-and-suggestions) 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](/docs/components/attachment/#states) page.

Files that upload above the field

[Complete TSX file](/examples/prompt-input/attachments.tsx)

```tsx
"use client";

import { RefreshCwIcon, XIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentGroup,
  AttachmentMedia,
  AttachmentTitle,
  type AttachmentState,
} 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";

type DraftFile = {
  id: string;
  name: string;
  progress: number;
  size: string;
  state: AttachmentState;
};

const initialFiles: DraftFile[] = [
  {
    id: "usage",
    name: "usage-q3.csv",
    progress: 40,
    size: "310 KB",
    state: "uploading",
  },
  {
    id: "contracts",
    name: "contracts.pdf",
    progress: 0,
    size: "2.4 MB",
    state: "error",
  },
  {
    id: "notes",
    name: "churn-notes.docx",
    progress: 100,
    size: "48 KB",
    state: "done",
  },
];

function formatSize(bytes: number) {
  return bytes < 1024 * 1024
    ? `${Math.max(1, Math.round(bytes / 1024))} KB`
    : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

function details(file: DraftFile) {
  if (file.state === "uploading") return `Uploading · ${file.progress}%`;
  if (file.state === "error") return "Upload failed";
  return file.size;
}

export default function Example() {
  const [files, setFiles] = useState(initialFiles);
  const field = useRef<HTMLTextAreaElement>(null);
  const uploading = files.some((file) => file.state === "uploading");

  // Stand-in for a real upload: report progress until each file is done.
  useEffect(() => {
    if (!uploading) return;
    const timer = setInterval(() => {
      setFiles((current) =>
        current.map((file) => {
          if (file.state !== "uploading") return file;
          const progress = Math.min(100, file.progress + 4);
          return {
            ...file,
            progress,
            state: progress === 100 ? "done" : "uploading",
          };
        }),
      );
    }, 250);
    return () => clearInterval(timer);
  }, [uploading]);

  // Retry and remove take their button away, so focus moves to the field.
  const retry = (id: string) => {
    field.current?.focus();
    setFiles((current) =>
      current.map((file) =>
        file.id === id ? { ...file, progress: 0, state: "uploading" } : file,
      ),
    );
  };
  const remove = (id: string) => {
    field.current?.focus();
    setFiles((current) => current.filter((file) => file.id !== id));
  };

  return (
    <div className="w-full max-w-lg">
      <PromptInput
        allowEmpty={files.some((file) => file.state === "done")}
        onSubmit={() => setFiles([])}
      >
        {files.length > 0 ? (
          <PromptInputAttachments>
            <AttachmentGroup label="Attachments">
              {files.map((file) => (
                <Attachment key={file.id} size="sm" state={file.state}>
                  <AttachmentMedia>
                    <FileTypeIcon name={file.name} size={24} />
                  </AttachmentMedia>
                  <AttachmentContent>
                    <AttachmentTitle>{file.name}</AttachmentTitle>
                    <AttachmentDescription>
                      {details(file)}
                    </AttachmentDescription>
                  </AttachmentContent>
                  <AttachmentActions>
                    {file.state === "error" ? (
                      <AttachmentAction
                        aria-label={`Retry the upload of ${file.name}`}
                        onClick={() => retry(file.id)}
                      >
                        <RefreshCwIcon />
                      </AttachmentAction>
                    ) : null}
                    <AttachmentAction
                      aria-label={
                        file.state === "uploading"
                          ? `Cancel the upload of ${file.name}`
                          : `Remove ${file.name}`
                      }
                      onClick={() => remove(file.id)}
                    >
                      <XIcon />
                    </AttachmentAction>
                  </AttachmentActions>
                </Attachment>
              ))}
            </AttachmentGroup>
          </PromptInputAttachments>
        ) : null}
        <PromptInputTextarea placeholder="Ask about these files" ref={field} />
        <PromptInputToolbar>
          <PromptInputToolbarGroup>
            <PromptInputAttachButton
              onFilesSelected={(picked) =>
                setFiles((current) => [
                  ...current,
                  ...picked.map((file): DraftFile => ({
                    id: crypto.randomUUID(),
                    name: file.name,
                    progress: 0,
                    size: formatSize(file.size),
                    state: "uploading",
                  })),
                ])
              }
            />
          </PromptInputToolbarGroup>
          <PromptInputToolbarGroup align="end">
            {/* Send waits until every file is uploaded. */}
            <PromptInputSubmit disabled={uploading} />
          </PromptInputToolbarGroup>
        </PromptInputToolbar>
      </PromptInput>
    </div>
  );
}
```

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](/docs/components/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.

Model and effort pickers in the toolbar

[Complete TSX file](/examples/prompt-input/model-and-effort.tsx)

```tsx
"use client";

import { ChevronDownIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/siteplane/components/button";
import {
  Menu,
  MenuGroup,
  MenuGroupLabel,
  MenuPopup,
  MenuRadioGroup,
  MenuRadioItem,
  MenuTrigger,
} from "@/siteplane/components/menu";
import {
  PromptInput,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputToolbar,
  PromptInputToolbarGroup,
} from "@/siteplane/components/prompt-input";

const models = ["Fast", "Thorough"];
const efforts = ["Low", "Medium", "High"];

export default function Example() {
  const [model, setModel] = useState("Thorough");
  const [effort, setEffort] = useState("Medium");

  return (
    <div className="w-full max-w-lg">
      {/* The app keeps both choices and sends them with the message. */}
      <PromptInput onSubmit={() => undefined}>
        <PromptInputTextarea placeholder="Ask anything about your workspace" />
        <PromptInputToolbar>
          <PromptInputToolbarGroup align="end">
            <Menu>
              <MenuTrigger
                render={
                  <Button
                    aria-label={`Model: ${model}`}
                    size="sm"
                    variant="ghost"
                  />
                }
              >
                {model}
                <ChevronDownIcon />
              </MenuTrigger>
              <MenuPopup align="end" side="top">
                <MenuGroup>
                  <MenuGroupLabel>Model</MenuGroupLabel>
                  <MenuRadioGroup onValueChange={setModel} value={model}>
                    {models.map((name) => (
                      <MenuRadioItem closeOnClick key={name} value={name}>
                        {name}
                      </MenuRadioItem>
                    ))}
                  </MenuRadioGroup>
                </MenuGroup>
              </MenuPopup>
            </Menu>
            <Menu>
              <MenuTrigger
                render={
                  <Button
                    aria-label={`Effort: ${effort}`}
                    size="sm"
                    variant="ghost"
                  />
                }
              >
                <span className="text-muted-foreground">Effort</span>
                {effort}
                <ChevronDownIcon />
              </MenuTrigger>
              <MenuPopup align="end" side="top">
                <MenuGroup>
                  <MenuGroupLabel>Effort</MenuGroupLabel>
                  <MenuRadioGroup onValueChange={setEffort} value={effort}>
                    {efforts.map((name) => (
                      <MenuRadioItem closeOnClick key={name} value={name}>
                        {name}
                      </MenuRadioItem>
                    ))}
                  </MenuRadioGroup>
                </MenuGroup>
              </MenuPopup>
            </Menu>
            <PromptInputSubmit />
          </PromptInputToolbarGroup>
        </PromptInputToolbar>
      </PromptInput>
    </div>
  );
}
```

## Empty state and suggestions

A new chat shows an [Empty State](/docs/components/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.

A new chat with suggestions

[Complete TSX file](/examples/prompt-input/empty-state.tsx)

```tsx
"use client";

import { useRef, useState } from "react";
import { Button } from "@/siteplane/components/button";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyTitle,
} from "@/siteplane/components/empty-state";
import {
  PromptInput,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputToolbar,
  PromptInputToolbarGroup,
} from "@/siteplane/components/prompt-input";

const suggestions = [
  "Which accounts are likely to churn this month?",
  "Summarize this week's support tickets",
  "Compare Q3 revenue by plan",
  "Draft a renewal email",
];

export default function Example() {
  const [draft, setDraft] = useState("");
  const field = useRef<HTMLTextAreaElement>(null);

  return (
    <div className="flex w-full max-w-lg flex-col">
      <Empty>
        <EmptyHeader>
          <EmptyTitle>What should we look into?</EmptyTitle>
          <EmptyDescription>
            Ask about accounts, usage or support. Northlight AI reads your
            workspace and shows its steps.
          </EmptyDescription>
        </EmptyHeader>
      </Empty>
      <div className="flex flex-col gap-4">
        <div className="flex flex-wrap gap-2">
          {suggestions.map((suggestion) => (
            <Button
              key={suggestion}
              onClick={() => {
                // The prompt goes into the draft, where it can be changed
                // before it is sent.
                setDraft(suggestion);
                field.current?.focus();
              }}
              shape="pill"
              size="sm"
              variant="outline"
            >
              {suggestion}
            </Button>
          ))}
        </div>
        {/* A controlled draft is the app's to clear. */}
        <PromptInput
          onSubmit={() => setDraft("")}
          onValueChange={setDraft}
          value={draft}
        >
          <PromptInputTextarea
            placeholder="Ask anything about your workspace"
            ref={field}
          />
          <PromptInputToolbar>
            <PromptInputToolbarGroup align="end">
              <PromptInputSubmit />
            </PromptInputToolbarGroup>
          </PromptInputToolbar>
        </PromptInput>
      </div>
    </div>
  );
}
```

## Surfaces

`variant` sets the surface of the field to `default`, `elevated`, `inset` or `soft`, the surfaces of [Input Group](/docs/components/input-group/#surfaces). 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.

```tsx
<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.

```tsx
"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 `FileUIPart`s. 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:

```tsx
"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](/docs/components/attachment/#file-parts) page shows.

## API reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| PromptInput.onSubmit | (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. |
| PromptInput.status | 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. |
| PromptInput.onStop | () => void | unset | Called when Stop is pressed. |
| PromptInput.value | string | unset | The draft of a controlled field. The app clears it after a send. |
| PromptInput.defaultValue | string | "" | The first draft of an uncontrolled field, which clears itself after a send. |
| PromptInput.onValueChange | (value: string) => void | unset | Called with the draft while it is typed. |
| PromptInput.submitOnEnter | 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. |
| PromptInput.allowEmpty | boolean | false | Lets a draft without text go, for a message of attachments alone. |
| PromptInput.disabled | boolean | false | Turns off the field, the attach button and Send. Stop stays pressable, so a reply that runs can always be stopped. |
| PromptInput.variant | default \| elevated \| inset \| soft | default | The surface of the field, from Input Group. |
| PromptInput.labels | Partial<PromptInputLabels> | English | Replaces the words: textarea (Message), send (Send message), stop (Stop) and attach (Attach files, also its tooltip). |
| PromptInputTextarea.minLines / maxLines | number | 1 / 8 | The least and the most height in lines. The field grows between them with its draft, then scrolls. |
| PromptInputToolbarGroup.align | start \| end | start | end puts the group at the end of the toolbar, also when there is no start group or the row wraps. |
| PromptInputAttachButton.onFilesSelected | (files: File[]) => void | required | Called with the picked files. The same file can be picked again. |
| PromptInputAttachButton.accept | string | unset | The file types the picker offers, as the accept attribute of a file input takes them. |
| PromptInputAttachButton.multiple | boolean | true | Whether the picker takes several files. |
| PromptInputSubmit | 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](/docs/components/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.

[Read the installed source](/source/prompt-input).

## 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](/docs/components/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.
