Prompt Input React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React prompt input for AI chat: a growing text box with attachments, tool chips, a model picker, dictation and a send and stop button.
Type and the box grows up to maxRows, then scrolls. With a keyboard Enter sends and Shift+Enter makes a new line (with submitShortcut mod+enter, Enter is a new line and Cmd/Ctrl+Enter sends); an IME composition is confirmed by Enter, never sent. On a phone Enter always makes a new line and the round button sends without closing the keyboard. Press + for Upload files (and Take a photo on touch); drop files anywhere on the composer (a dashed overlay shows where) or paste a screenshot. Images show thumbnails, files a colored type tile; uploads show a progress ring, failures a retry button, and every attachment has a remove button (Backspace in an empty box jumps to the last one, Backspace again removes it). Tool chips toggle with a click (in a composer narrower than 512px, a phone or a side panel, they live in the + menu, a bottom sheet on phones, and active ones stay visible); the model name opens the model list (a bottom sheet on phones). The mic dictates at the caret: finished words land in the text, words still being heard stay muted in the voice pill. While the answer streams the button is Stop (and stopShortcut; a bare key like Escape works while focus is in the composer); ArrowUp in an empty box edits the last message; focusShortcut (e.g. Shift+Escape) focuses the box from anywhere. Past 80% of maxLength or tokenLimit a counter appears and turns red past the limit, which blocks sending.
Prompt Input is a Pro React component in the React AI components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add prompt-input, together with Textarea, Button, Toggle, Tooltip, Dropdown Menu and 18 more and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add prompt-inputPrompt Input is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import PromptInput from @/components/ui/prompt-input.
"use client"
import { useState } from "react"
import { Brain, Database, Globe, Sparkles, Gem } from "lucide-react"
import { PromptInput, type AiModel, type PromptInputMessage, type PromptInputStatus, type PromptTool } from "@/components/ui/prompt-input"
const MODELS: AiModel[] = [
{ id: "standard", name: "Acme Standard", group: "Acme AI", description: "Balanced, for everyday work", icon: <Sparkles />, recommended: true },
{ id: "pro", name: "Acme Pro", group: "Acme AI", description: "Deep reasoning for analysis and reports", icon: <Gem />, isNew: true },
]
const TOOLS: PromptTool[] = [
{ id: "web", label: "Web search", icon: <Globe /> },
{ id: "reasoning", label: "Extended thinking", icon: <Brain /> },
{ id: "crm", label: "CRM data", icon: <Database /> },
]
export function Composer({ ask, stop }: { ask: (message: PromptInputMessage) => Promise<void>; stop: () => void }) {
const [status, setStatus] = useState<PromptInputStatus>("idle")
async function send(message: PromptInputMessage) {
setStatus("streaming")
try {
await ask(message)
} finally {
setStatus("idle")
}
}
async function upload(file: File, { signal, onProgress }: { signal: AbortSignal; onProgress: (fraction: number) => void }) {
const body = new FormData()
body.append("file", file)
onProgress(0.5)
const response = await fetch("/api/files", { method: "POST", body, signal })
if (!response.ok) throw new Error("Upload failed")
return (await response.json()) as { url: string }
}
return (
<PromptInput
models={MODELS}
tools={TOOLS}
defaultActiveTools={["crm"]}
voice
status={status}
onSubmit={send}
onStop={stop}
onUpload={upload}
stopShortcut="escape"
focusShortcut="shift+escape"
labels={{ placeholder: "Ask the Acme assistant" }}
/>
)
}Props
defaultValuedefault""stringStarting text while uncontrolled. Use value / onValueChange to control it; a rejected onSubmit restores the text through onValueChange.
valuestringThe text, controlled. Pair it with onValueChange.
onValueChange(value: string) => voidThe new text on every change, dictation and restores included.
onSubmit(message: PromptInputMessage) => (void | Promise<unknown>)Runs on Enter or the button when there is text or a ready attachment: { text (trimmed), attachments (ready or local), model, tools }. The composer clears at once and keeps focus; if the promise rejects, the text and files come back and an error is announced.
statusdefault"idle""idle" | "submitting" | "streaming"submitting: the button spins; streaming: it becomes Stop. Sending is blocked while not idle, typing is not.
onStop() => voidThe Stop button and stopShortcut while status is streaming.
placeholderdefault"Ask anything"stringAlso the textarea's accessible name unless aria-labelledby is passed.
variantdefault"elevated""elevated" | "outline" | "soft"elevated: a floating white panel with a soft shadow (ChatGPT); outline: a bordered field; soft: the filled well of the form fields.
sizedefault"md""sm" | "md" | "lg"Text 14 / 15 / 16px (16px on phones always, so iOS never zooms), toolbar buttons 28 / 32 / 36px, padding 8 / 10 / 12px.
radiusdefault"lg""md" | "lg" | "full"md 16px, lg 24px; full is a 28px pill while the text is one line and 24px once it grows or holds attachments.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The send button, active tool chips and the focus ring's tint. Brand orange is opt-in.
colordefault""stringAny CSS color; replaces the tone. Text on the send button turns black or white by lightness.
minRowsdefault1numberHeight of the empty text area in rows.
maxRowsdefault8numberGrows with the text up to this many rows, then scrolls (native field-sizing, no JS per keystroke).
positiondefault"static""static" | "sticky" | "fixed"fixed: pinned to the viewport bottom, lifted above the on-screen keyboard without re-rendering, safe-area padded, and writes its height as --prompt-input-offset on <html> so the page can pad its content; sticky: the bottom of its scroll parent (inside a chat thread). Both center the box in a 768px reading column over a fade.
safeAreadefaulttruebooleanPads the bottom by the home-indicator inset (at least 12px) when fixed or sticky.
submitShortcutdefault"enter""enter" | "mod+enter"enter: Enter sends and Shift+Enter makes a new line on fine pointers (touch keyboards always insert a new line); mod+enter: Enter is a new line and Cmd/Ctrl+Enter sends. Cmd/Ctrl+Enter always sends.
maxLengthdefault0numberA soft character limit: the counter appears near it and turns red past it, and sending is blocked until the text is shorter. 0 = none.
tokenLimitdefault0numberEstimated tokens (estimateTokens): the hint reads "1,2 K / 8 K tokens" near the limit. 0 = none.
showCountdefault"auto""auto" | "always" | "never"auto: the hint appears past 80% of maxLength / tokenLimit (warning), and turns danger past 100%; always: always shown (the token estimate without a limit); never: hidden, the limit still blocks.
allowAttachmentsdefaulttruebooleanThe + button, drop and paste of files.
acceptdefault"image/*,application/pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.json"stringAllowed types, like the input's accept attribute; checked by mime type and by extension. Defaults to DEFAULT_PROMPT_INPUT_ACCEPT.
maxFilesdefault10numberAt most this many attachments; extra files are rejected with one message.
maxFileSizedefault20971520numberBytes per file (20 MB).
cameradefaulttruebooleanA "Take a photo" entry (capture="environment") in the + menu on touch devices.
voicedefaultfalsebooleanA dictation button (VoiceInput). Its transcript goes in at the caret, replacing a selection; while recording the voice pill takes the toolbar.
focusShortcutdefault""stringFocuses the box from anywhere and puts the caret at the end, e.g. "shift+escape". Empty binds nothing.
stopShortcutdefault""stringStops a streaming reply, e.g. "escape". A bare key works while focus is in the composer (so a page dialog keeps its Escape), a combo like "mod+shift+x" works anywhere; an open menu or model list closes first. Empty binds nothing.
autoFocusdefaultfalsebooleanFocuses the box on mount on desktops only; a phone keyboard never pops up by surprise.
disableddefaultfalsebooleanDims and blocks the box and every toolbar control.
namedefault""stringThe textarea's name for forms.
localedefault""stringNumber formatting of sizes, percentages and token counts (12.5K in en-US). Empty uses the UIProvider locale, then the browser's.
attachmentsChatAttachment[]Controlled attachments ({ id, name, type, size, url, previewUrl, status, progress, error }). Pair it with onAttachmentsChange.
defaultAttachmentsdefault"[]"ChatAttachment[]Starting attachments while uncontrolled (a restored draft).
onAttachmentsChange(attachments: ChatAttachment[]) => voidEvery add, progress step, finish, failure and removal.
onUpload(file: File, { signal, onProgress }) => Promise<unknown>Uploads each added file with progress (0..1, one render per 100ms at most); resolve with a url string or { url } to set attachment.url. Removing a file aborts through the signal, Retry runs it again. Without it attachments stay local, with object-URL previews revoked on removal and unmount.
onRetry(attachment: ChatAttachment) => voidRetry on a failed attachment the composer holds no File for (restored from a draft or added by the app).
modelsAiModel[]Shows a ModelPicker (ghost, opening upward; a bottom sheet on phones) in the toolbar.
modelstringThe chosen model id, controlled. Uncontrolled it starts at defaultModel, else the first recommended model.
defaultModeldefault""stringStarting model id while uncontrolled. Empty starts at the first recommended model (Acme Standard in the demo).
onModelChange(model: string) => voidA model was picked.
toolsPromptTool[]Toggle chips in the toolbar ({ id, label, icon, description, disabled }); in a composer narrower than 512px they move into the + menu (a sheet on phones) as checkbox rows and only active ones stay in the toolbar; under 672px the chips show as round icons with the name in a tooltip.
activeToolsstring[]Active tool ids, controlled.
defaultActiveToolsdefault"[]"string[]Active tool ids while uncontrolled.
onActiveToolsChange(tools: string[]) => voidA tool was switched on or off (ids in the order of tools).
onVoice(result: VoiceInputResult) => (void | Promise<unknown>)Makes the mic send audio messages (the blob and transcript) instead of dictating into the box.
onEditLast() => voidArrowUp in an empty box with the caret at the start: edit the last message.
toolbarStartReactNodeExtra controls after the model picker (a project picker).
toolbarEndReactNodeExtra controls before the mic (a usage ring).
labelsPartial<PromptInputLabels>Every string: placeholder, send, stop, attach, upload, photo, tools, cancel, attachments, remove(name), retry, uploading(name, percent), progress(percent), uploadFailed, drop, tooMany(max), tooLarge(name, max), typeNotAllowed(name), tokens(used, limit), tokenCount(used), characters(count, max), overLimit, newlineHint, attached(name), removed(name), sendFailed.
modelPickerPropsPartial<ModelPickerProps>Passed to the model picker: labels, shortcut, groupBy, showSpeed.
voiceInputPropsPartial<VoiceInputProps>Passed to the voice button: labels, lang, maxDuration, mode.
classNamesPartial<Record<"box" | "textarea" | "attachments" | "attachment" | "toolbar" | "submit" | "hint" | "drop", string>>Extra classes for the inner parts.
classNamestringExtra classes for the root, merged last. Other props (id, aria-*, data-*) go there too.
Parts
PromptInputAttachments
componentThe attachment rail on its own: thumbnails and file tiles with size, progress ring, error and retry, and a remove button each; a horizontal rail with edge fades. Props: { attachments, onRemove?(attachment, { keyboard }), onRetry?, size?, locale?, disabled?, labels?, classNames?, ...ul }.
PromptInputSubmit
componentThe round send button: an arrow, a spinner while submitting, a Stop square while streaming; dimmed but focusable (aria-disabled) with nothing to send. Props: { status?, canSubmit?, onStop?, size?, shortcut?, tooltip?, labels?, ...ButtonProps }.
PromptInputTool
componentA tool chip: an outline pill toggle with aria-pressed and an optional tooltip. Props: { label, icon?, description?, size?, ...ToggleProps }.
readAttachments
functionValidates picked, dropped or pasted files by type (mime or extension), size and count, with object-URL previews for images. Call it from an event handler. Signature: (files, { accept?, maxFiles?, maxFileSize?, current?, preview? }) => { accepted: ChatAttachment[]; rejected: { file; reason: "type" | "size" | "count" }[]; files: Map<string, File> }.
DEFAULT_PROMPT_INPUT_LABELS
constantThe English strings, to spread and override. Type: PromptInputLabels.
DEFAULT_PROMPT_INPUT_ACCEPT
constantThe default accept list: images, PDF, Word, Excel, CSV, plain text, Markdown and JSON. Extend it, e.g. `${DEFAULT_PROMPT_INPUT_ACCEPT},.pptx`.
Examples
Minimal
A support chat: outline, pill-shaped, attachments and Send only.
Limits
maxLength and tokenLimit with a counter that warns, then blocks.
Uploading
Progress rings, a failed upload with Retry, controlled attachments.
Streaming
Send becomes Stop; Escape stops too.
Phone, fixed
Pinned to the bottom above the keyboard, with suggestion chips above it.
Brand
tone="brand" on the send button and the active tool chips.
Sizes and variants
sm soft, md elevated, lg outline.
Dependencies
Files
- components/ui/prompt-input.tsx
FAQ
Is the React Prompt Input component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Prompt Input?
Run npx wingo-ui@latest add prompt-input. It also adds the Wingo UI items it builds on (Textarea, Button, Toggle, Tooltip, Dropdown Menu and 18 more) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/prompt-input.
How does Prompt Input behave on phones?
On a phone Enter always makes a new line and the round button sends without closing the keyboard.
See also
Used in
More React AI components
Articles using this component
- AI Elements Alternative: 5 React AI Chat Kits Compared
Building UI with AI agents11 min read
- React Prompt Input: Enter, IME, Files, Stop and Voice
Building UI with AI agents10 min read
- React Speech to Text Input: Mic, Waveform, Fallback
Building UI with AI agents9 min read
- iOS Safari Keyboard Position Fixed: Keep a Composer Visible
Mobile UI in React9 min read