HextaUI

Prompt Input

A chat composer that grows as you type. Handles file uploads, @ and / menus at the caret and messages typed while a reply streams.

Open in v0Open in new tab

The composer is the part of a chat people touch most, so every keystroke has to feel right. Prompt Input starts as one calm line with the + on the left and send on the right, grows into a card once the text wraps or files are added, and settles back when cleared.

Enter sends and Shift+Enter adds a line, and input methods for Japanese, Chinese and Korean are never interrupted. On touch screens Enter adds a line, since the keyboard has no Shift. Inserts go through the browser’s own undo stack, so ⌘Z always works, and long pastes become an attachment you can preview or put back inline.

Everything is composed from parts that share one context. Use just the field and send button, or add file uploads with progress and retry, @ and / menus at the caret, a model picker, an effort slider, a context ring with a token and cost breakdown, dictation, tool chips, a queue for messages sent while a reply streams, and drafts that survive a reload.

Prompt Input is free. Install it without an account or a token, and use it under the Pro License in as many projects as you like.

pnpm dlx shadcn@latest add https://hextaui.com/r/pro/prompt-input.json

Build the rest of the chat

Prompt Input is free. These blocks build the rest of the chat around it, and come with HextaUI Pro.

With the AI SDK

Pass useChat’s status and stop. Files arrive as FileUIPart with a data URL, and a message typed while a reply streams is queued and sent when it finishes. Send usage from the server as message metadata to fill the context ring.

"use client"

import { useChat } from "@ai-sdk/react"
import type { FileUIPart, LanguageModelUsage, UIMessage } from "ai"

import {
  PromptInputAddMenu,
  PromptInputContextUsage,
  PromptInputMeta,
} from "@/components/blocks/prompt-input/prompt-controls"
import {
  PromptInput,
  PromptInputAttachments,
  PromptInputBody,
  PromptInputActions,
  PromptInputQueue,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputTools,
  type PromptMessage,
} from "@/components/blocks/prompt-input/prompt-input"

function toDataUrl(file: File) {
  return new Promise<string>((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => resolve(String(reader.result))
    reader.onerror = () => reject(reader.error)
    reader.readAsDataURL(file)
  })
}

type ChatMessage = UIMessage<{ usage?: LanguageModelUsage }>

export function Composer({ chatId }: { chatId: string }) {
  const { messages, sendMessage, status, stop } = useChat<ChatMessage>({
    id: chatId,
  })
  const usage = messages.findLast((message) => message.metadata?.usage)
    ?.metadata?.usage

  const send = async ({ text, files }: PromptMessage) => {
    const parts: FileUIPart[] = await Promise.all(
      files.map(async ({ file }) => ({
        type: "file" as const,
        mediaType: file.type,
        filename: file.name,
        url: await toDataUrl(file),
      }))
    )
    await sendMessage({ text, files: parts })
  }

  return (
    <PromptInput
      status={status}
      onStop={stop}
      onSubmit={send}
      accept="image/*,.pdf"
      maxFileSize={20 * 1024 * 1024}
      draftKey={chatId}
    >
      <PromptInputQueue />
      <PromptInputBody>
        <PromptInputAttachments />
        <PromptInputTools>
          <PromptInputAddMenu />
        </PromptInputTools>
        <PromptInputTextarea />
        <PromptInputActions>
          <PromptInputSubmit />
        </PromptInputActions>
      </PromptInputBody>
      <PromptInputMeta>
        <PromptInputContextUsage
          usage={usage}
          limit={200000}
          pricing={{ input: 3, cachedInput: 0.3, output: 15 }}
        />
      </PromptInputMeta>
    </PromptInput>
  )
}

Model, effort and dictation

Put the model picker, effort slider and context ring under the composer with PromptInputMeta. The ring breaks usage down into input, cache, output and reasoning, prices it, and offers to compact the conversation near the limit. Turn tools on from the + menu and show them as chips. Pass transcribe to send recordings to your own speech model, and autoSend to send as soon as the text lands.

"use client"

import * as React from "react"
import { IconWorld } from "@tabler/icons-react"

import { DropdownMenuCheckboxItem } from "@/components/ui/dropdown-menu"

import {
  PromptInputAddMenu,
  PromptInputContextUsage,
  PromptInputDictation,
  PromptInputEffort,
  PromptInputMeta,
  PromptInputModelSelect,
  PromptInputTool,
  type PromptTokenUsage,
} from "@/components/blocks/prompt-input/prompt-controls"
import {
  PromptInput,
  PromptInputActions,
  PromptInputAttachments,
  PromptInputBody,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputTools,
  type PromptMessage,
} from "@/components/blocks/prompt-input/prompt-input"

const models = [
  { value: "large", label: "Large" },
  { value: "small", label: "Small" },
]

const efforts = [
  { value: "low", label: "Low" },
  { value: "medium", label: "Medium" },
  { value: "high", label: "High" },
  { value: "max", label: "Max" },
]

export function Composer({
  onSend,
  usage,
  onCompact,
  transcribe,
}: {
  onSend: (message: PromptMessage) => void
  usage: PromptTokenUsage
  onCompact: () => Promise<void>
  transcribe: (audio: Blob) => Promise<string>
}) {
  const [model, setModel] = React.useState("large")
  const [effort, setEffort] = React.useState("medium")
  const [thinking, setThinking] = React.useState(true)
  const [search, setSearch] = React.useState(false)

  return (
    <PromptInput onSubmit={onSend} focusOnType>
      <PromptInputBody>
        <PromptInputAttachments />
        <PromptInputTools>
          <PromptInputAddMenu>
            <DropdownMenuCheckboxItem
              indicator="end"
              checked={search}
              onCheckedChange={setSearch}
            >
              <IconWorld />
              Web search
            </DropdownMenuCheckboxItem>
          </PromptInputAddMenu>
          {search ? (
            <PromptInputTool
              icon={<IconWorld />}
              tone="blue"
              onRemove={() => setSearch(false)}
            >
              Search
            </PromptInputTool>
          ) : null}
        </PromptInputTools>
        <PromptInputTextarea autoFocus />
        <PromptInputActions>
          <PromptInputDictation transcribe={transcribe} autoSend />
          <PromptInputSubmit />
        </PromptInputActions>
      </PromptInputBody>
      <PromptInputMeta>
        <PromptInputModelSelect
          models={models}
          value={model}
          onValueChange={setModel}
        />
        <PromptInputEffort
          levels={efforts}
          value={effort}
          onValueChange={setEffort}
          recommended="medium"
          thinking={thinking}
          onThinkingChange={setThinking}
        />
        <PromptInputContextUsage
          usage={usage}
          limit={200000}
          model={models.find((item) => item.value === model)?.label}
          pricing={{ input: 3, cachedInput: 0.3, output: 15 }}
          onCompact={onCompact}
        />
      </PromptInputMeta>
    </PromptInput>
  )
}

Mentions and commands

Add a menu per trigger. @ turns a pick into a chip above the text, and / inserts the command. Arrow keys move, Enter or Tab picks and Escape closes, all without leaving the text.

"use client"

import { IconBrandTypescript, IconTrash } from "@tabler/icons-react"

import {
  PromptInput,
  PromptInputAttachments,
  PromptInputBody,
  PromptInputActions,
  PromptInputSubmit,
  PromptInputTextarea,
  type PromptMessage,
} from "@/components/blocks/prompt-input/prompt-input"
import { PromptInputMenu, type PromptMenuItem } from "@/components/blocks/prompt-input/prompt-menu"

const files: PromptMenuItem[] = [
  {
    value: "app/page.tsx",
    label: "page.tsx",
    description: "app",
    icon: <IconBrandTypescript />,
  },
  {
    value: "lib/utils.ts",
    label: "utils.ts",
    description: "lib",
    icon: <IconBrandTypescript />,
  },
]

const commands: PromptMenuItem[] = [
  { value: "explain", label: "explain", description: "Explain code step by step" },
  { value: "test", label: "test", description: "Write tests for a file" },
  {
    value: "clear",
    label: "clear",
    description: "Start a new conversation",
    icon: <IconTrash />,
    insert: "",
  },
]

export function Composer({
  onSend,
  onClear,
}: {
  onSend: (message: PromptMessage) => void
  onClear: () => void
}) {
  return (
    <PromptInput onSubmit={onSend}>
      <PromptInputBody>
        <PromptInputAttachments />
        <PromptInputTextarea placeholder="Ask anything, @ to add files, / for commands" />
        <PromptInputActions>
          <PromptInputSubmit />
        </PromptInputActions>
      </PromptInputBody>
      <PromptInputMenu trigger="@" items={files} />
      <PromptInputMenu
        trigger="/"
        items={commands}
        onSelect={(item) => {
          if (item.value === "clear") onClear()
        }}
      />
    </PromptInput>
  )
}

Uploading files

Give upload a function that reports progress and honours the abort signal. Throw UploadError with retryable: false for errors a retry can’t fix, like a file that’s too large for the server.

"use client"

import {
  PromptInput,
  PromptInputAttachments,
  PromptInputBody,
  PromptInputActions,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputTools,
  type PromptMessage,
} from "@/components/blocks/prompt-input/prompt-input"
import { PromptInputAddMenu } from "@/components/blocks/prompt-input/prompt-controls"
import { UploadError, type PromptUpload } from "@/components/blocks/prompt-input/use-prompt-files"

const upload: PromptUpload = (file, { signal, onProgress }) =>
  new Promise((resolve, reject) => {
    const request = new XMLHttpRequest()
    request.open("POST", "/api/upload")
    request.upload.onprogress = (event) => {
      if (event.lengthComputable) onProgress((event.loaded / event.total) * 100)
    }
    request.onload = () => {
      if (request.status < 300) {
        resolve(JSON.parse(request.responseText))
        return
      }
      reject(
        new UploadError(
          request.status === 413 ? "Too large for the server" : "Upload failed",
          { retryable: request.status === 429 || request.status >= 500 }
        )
      )
    }
    request.onerror = () => reject(new UploadError("Network error. Try again."))
    signal.addEventListener("abort", () => request.abort())
    const body = new FormData()
    body.append("file", file)
    request.send(body)
  })

export function Composer({ onSend }: { onSend: (message: PromptMessage) => void }) {
  return (
    <PromptInput
      onSubmit={onSend}
      upload={upload}
      accept="image/*,.pdf,.csv"
      maxFiles={5}
      maxFileSize={25 * 1024 * 1024}
      globalDrop
    >
      <PromptInputBody>
        <PromptInputAttachments />
        <PromptInputTools>
          <PromptInputAddMenu />
        </PromptInputTools>
        <PromptInputTextarea />
        <PromptInputActions>
          <PromptInputSubmit />
        </PromptInputActions>
      </PromptInputBody>
    </PromptInput>
  )
}

Agent harness

Wrap the body in PromptInputInset and add a PromptInputFooter. Use PromptInputSelect for the mode, where it runs and which folder it works in.

"use client"

import * as React from "react"
import {
  IconCloud,
  IconCode,
  IconDeviceLaptop,
  IconFolder,
  IconFolderOpen,
  IconListCheck,
} from "@tabler/icons-react"

import {
  PromptInputAddMenu,
  PromptInputFooter,
  PromptInputInset,
  PromptInputModel,
  PromptInputSelect,
} from "@/components/blocks/prompt-input/prompt-controls"
import {
  PromptInput,
  PromptInputActions,
  PromptInputAttachments,
  PromptInputBody,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputTools,
  type PromptMessage,
} from "@/components/blocks/prompt-input/prompt-input"

const modes = [
  { value: "code", label: "Code", icon: <IconCode /> },
  { value: "plan", label: "Plan", icon: <IconListCheck /> },
]

const locations = [
  { value: "local", label: "Local", icon: <IconDeviceLaptop /> },
  { value: "cloud", label: "Cloud", icon: <IconCloud /> },
]

const folders = [
  { value: "~/code/app", label: "app", icon: <IconFolder /> },
]

const models = [
  { value: "large", label: "Large" },
  { value: "small", label: "Small" },
]

const efforts = [
  { value: "low", label: "Low" },
  { value: "medium", label: "Medium" },
  { value: "high", label: "High" },
]

export function AgentComposer({
  onSend,
}: {
  onSend: (message: PromptMessage & { mode: string }) => void
}) {
  const [mode, setMode] = React.useState("code")
  const [location, setLocation] = React.useState("local")
  const [folder, setFolder] = React.useState<string>()
  const [model, setModel] = React.useState("large")
  const [effort, setEffort] = React.useState("medium")

  return (
    <PromptInput
      layout="expanded"
      onSubmit={(message) => onSend({ ...message, mode })}
    >
      <PromptInputInset>
        <PromptInputBody>
          <PromptInputAttachments />
          <PromptInputTools>
            <PromptInputAddMenu />
            <PromptInputSelect
              label="Mode"
              options={modes}
              value={mode}
              onValueChange={setMode}
              shortcut="alt+m"
            />
          </PromptInputTools>
          <PromptInputTextarea className="min-h-16" />
          <PromptInputActions>
            <PromptInputModel
              models={models}
              model={model}
              onModelChange={setModel}
              levels={efforts}
              effort={effort}
              onEffortChange={setEffort}
            />
            <PromptInputSubmit />
          </PromptInputActions>
        </PromptInputBody>
        <PromptInputFooter>
          <PromptInputSelect
            label="Run on"
            options={locations}
            value={location}
            onValueChange={setLocation}
          />
          <PromptInputSelect
            label="Folder"
            options={folders}
            value={folder}
            onValueChange={setFolder}
            placeholder="Choose folder"
            icon={<IconFolderOpen />}
          />
        </PromptInputFooter>
      </PromptInputInset>
    </PromptInput>
  )
}

Anatomy

The parts you compose, from the outside in.

PartDescription
PromptInputThe form and shared state: text, files, mentions, queue and status.
PromptInputBodyThe rounded surface that changes between compact and expanded.
PromptInputTextareaThe field. All keyboard handling lives here.
PromptInputToolsLeft side: the + menu and tool chips.
PromptInputActionsRight side: dictation and send.
PromptInputSubmitSend, stop or queue, depending on status and text.
PromptInputAttachmentsFile and mention chips above the text.
PromptInputMenuA caret menu for one trigger character.
PromptInputMetaThe row under the composer for the model, effort and context controls.
PromptInputModelOne control for the model and effort, with the thinking switch.
PromptInputInsetA muted shell around the body that holds a footer row, for agent harnesses.
PromptInputFooterThe row inside the inset shell for location, folder and session controls.
PromptInputSelectA compact picker for modes, run location, folders or repositories.
PromptInputQueue, PromptInputSuggestions, PromptInputMeterQueued messages, starter prompts and a character meter near maxLength.

PromptInput

Also accepts every form prop except onSubmit and defaultValue.

PropTypeDefault
onSubmitReceives { text, files, mentions }. Return a promise to keep the text until it resolves.
(message: PromptMessage) => void | Promise<unknown>–
statusPass useChat’s status. While busy, send becomes stop or queue.
"ready" | "submitted" | "streaming" | "error""ready"
onStopCalled from the stop button or Escape.
() => void–
onEditLastReturn the last message to load it with ↑ in an empty field.
() => string | void–
valueControlled text.
string–
defaultValueInitial text.
string""
onValueChangeCalled as the text changes.
(value: string) => void–
submitOnWhich key sends. ⌘↵ always sends.
"enter" | "mod+enter""enter"
pasteLimitCharacters before a paste becomes an attachment.
number | false5000
maxLengthHard limit, shown by PromptInputMeter.
number–
draftKeySaves unsent text to local storage under this key.
string–
globalDropAccept files dropped anywhere on the page.
booleanfalse
focusOnTypeTyping while nothing else has focus starts typing here.
booleanfalse
layoutForce a layout instead of switching.
"auto" | "compact" | "expanded""auto"
acceptAccepted file types, like the input attribute.
string–
maxFilesMost files at once.
number–
maxFileSizeLargest file in bytes.
number–
uploadUploads each file. Throw UploadError with retryable: false for permanent failures.
(file, { signal, onProgress }) => Promise<unknown>–
onRejectCalled for files over a limit or of the wrong type.
(file: File, reason: string) => void–
disabledDisables the whole composer.
booleanfalse
PropTypeDefault
triggerThe character that opens it, such as @ or /.
string–
items{ value, label, description?, icon?, hint?, keywords?, insert? }
PromptMenuItem[]–
modeMentions add a chip; text inserts insert ?? label.
"mention" | "text"–
labelSection label at the top of the menu.
string–
limitMost rows shown.
number–
onSelectCalled after a pick.
(item: PromptMenuItem) => void–

PromptInputModel

The model picker and effort slider in one control. Also takes every PromptInputEffort prop except value and onValueChange.

PropTypeDefault
modelsThe first nine get 1–9 shortcuts.
{ value, label }[]–
modelSelected model.
string–
onModelChangeCalled on pick.
(value: string) => void–
levelsEffort levels, from least to most.
{ value, label }[]–
effortSelected effort.
string–
onEffortChangeCalled as the slider moves.
(value: string) => void–
PropTypeDefault
modelsThe first nine get 1–9 shortcuts.
{ value, label }[]–
valueSelected model.
string–
onValueChangeCalled on pick.
(value: string) => void–
PropTypeDefault
levelsFrom least to most.
{ value, label }[]–
valueSelected level.
string–
onValueChangeCalled as the slider moves.
(value: string) => void–
recommendedMarks one level as recommended.
string–
thinkingShows the extended-thinking switch when set.
boolean–
onThinkingChangeCalled from the switch.
(thinking: boolean) => void–
thinkingLabelSwitch label.
string–
descriptionHelp text in the tooltip.
string–

PromptInputContextUsage

A ring that fills as the context window does. Hover, click or press Enter to see the breakdown.

PropTypeDefault
usedTokens used. Defaults to the total in usage.
number–
limitContext window size.
number–
usageAI SDK usage, flat or nested. Splits the bar into input, cached input, output and reasoning.
LanguageModelUsage–
modelModel name shown at the top.
string–
pricingDollars per million tokens. Prices each row and adds a total.
{ input, output, cachedInput?, reasoning? }–
costTotal cost in dollars, when you already have it.
number–
onCompactShows Compact conversation from 80% full. Return a promise to show progress. The popover closes when it resolves.
() => void | Promise<unknown>–
limitsExtra rows, such as plan usage.
{ label, used, detail? }[]–
limitsLabelHeading for those rows.
string–
PropTypeDefault
transcribeSend the recording to your speech model. Without it, the browser’s speech recognition is used.
(audio: Blob) => Promise<string>–
shortcutStarts and finishes a recording from anywhere.
string | false"ctrl+shift+d"
autoSendSend as soon as the transcript lands.
booleanfalse
PropTypeDefault
iconShown in the chip, and replaced by × on hover.
ReactNode–
childrenTool name.
string–
toneChip color.
"blue" | "green" | "amber" | "violet" | "pink""blue"
onRemoveCalled when the chip is clicked.
() => void–
PropTypeDefault
optionsThe choices. Descriptions show under each label.
{ value, label, icon?, description? }[]–
valueSelected option. Leave it unset to show the placeholder.
string–
onValueChangeCalled on pick.
(value: string) => void–
labelAccessible name and tooltip, like Mode or Run on.
string–
placeholderShown when nothing is selected.
string–
iconShown when the selected option has no icon.
ReactNode–
headingLabel at the top of the menu.
string–
shortcutCycles through the options from anywhere, like alt+m.
string–
childrenExtra menu items below the options, like Open folder.
ReactNode–
KeyAction
EnterSends. On touch screens it adds a line instead.
ShiftEnterAdds a line.
⌘EnterAlways sends, even when submitOn is mod+enter.
EscStops a streaming reply, closes a menu, or cancels a recording.
↑In an empty field, brings back your last message when onEditLast is set.
↑↓Moves through an open @ or / menu.
EnterTabPicks the highlighted menu row.
1–9Picks a model while the model menu is open.
←→Moves the effort slider. Home and End jump to the ends.
CtrlShiftDStarts dictation, and inserts the transcript when pressed again.
AltMCycles a PromptInputSelect that has shortcut="alt+m", like the agent mode.
  • The field has a visible placeholder and an accessible name, and every icon button has a label and tooltip with its shortcut.
  • The caret menu is a combobox with aria-activedescendant, so focus never leaves the text while moving through it.
  • Uploads, rejections, queue changes and dictation are announced through a polite live region.
  • The effort control is a slider with a value text, and it keeps working with arrow keys and Home and End.
  • The context ring opens on hover, click, Enter or Space, its label says how much of the context is used, and the breakdown is a table with a caption.
  • Inputs are at least 16px on touch screens, so iOS never zooms in on focus.
  • With reduced motion, height changes, the waveform and the effort fill all settle without animating.

Built with

The free HextaUI components Prompt Input is made from. Each one installs on its own.

Code

10 files, added to components/blocks/prompt-input.