Siteplane UI

Prompt Input

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

Markdown

Northlight AI can make mistakes. Check important information.

"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

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

Complete 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/menu

Usage

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
        └── 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.

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

"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 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.

"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 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.

What should we look into?
Ask about accounts, usage or support. Northlight AI reads your workspace and shows its steps.
"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. 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

Component properties
PropTypeDefaultDescription
PromptInput.onSubmit(message: { text: string }) => voidrequiredCalled 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.statusready | submitted | streaming | errorreadyWhere the chat is in a turn, in the words of the AI SDK. submitted and streaming turn Send into Stop.
PromptInput.onStop() => voidunsetCalled when Stop is pressed.
PromptInput.valuestringunsetThe draft of a controlled field. The app clears it after a send.
PromptInput.defaultValuestring""The first draft of an uncontrolled field, which clears itself after a send.
PromptInput.onValueChange(value: string) => voidunsetCalled with the draft while it is typed.
PromptInput.submitOnEnteralways | desktop | neveralwaysWhich 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.allowEmptybooleanfalseLets a draft without text go, for a message of attachments alone.
PromptInput.disabledbooleanfalseTurns off the field, the attach button and Send. Stop stays pressable, so a reply that runs can always be stopped.
PromptInput.variantdefault | elevated | inset | softdefaultThe surface of the field, from Input Group.
PromptInput.labelsPartial<PromptInputLabels>EnglishReplaces the words: textarea (Message), send (Send message), stop (Stop) and attach (Attach files, also its tooltip).
PromptInputTextarea.minLines / maxLinesnumber1 / 8The least and the most height in lines. The field grows between them with its draft, then scrolls.
PromptInputToolbarGroup.alignstart | endstartend puts the group at the end of the toolbar, also when there is no start group or the row wraps.
PromptInputAttachButton.onFilesSelected(files: File[]) => voidrequiredCalled with the picked files. The same file can be picked again.
PromptInputAttachButton.acceptstringunsetThe file types the picker offers, as the accept attribute of a file input takes them.
PromptInputAttachButton.multiplebooleantrueWhether the picker takes several files.
PromptInputSubmitButton propsround accent icon ButtonSend, 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.

Read the installed source.

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.