# Prompt Input

> 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: React component (AI Kit)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add prompt-input`
- Docs: https://wingo-ui.com/components/prompt-input

## Interaction

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.

## Installation

```bash
npx wingo-ui@latest add prompt-input
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/prompt-input` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/prompt-input.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Textarea](https://wingo-ui.com/components/textarea) ([Markdown](https://wingo-ui.com/components/textarea.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Toggle](https://wingo-ui.com/components/toggle) ([Markdown](https://wingo-ui.com/components/toggle.md))
- [Tooltip](https://wingo-ui.com/components/tooltip) ([Markdown](https://wingo-ui.com/components/tooltip.md))
- [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) ([Markdown](https://wingo-ui.com/components/dropdown-menu.md))
- [Progress](https://wingo-ui.com/components/progress) ([Markdown](https://wingo-ui.com/components/progress.md))
- [Spinner](https://wingo-ui.com/components/spinner) ([Markdown](https://wingo-ui.com/components/spinner.md))
- [File Upload](https://wingo-ui.com/components/file-upload) ([Markdown](https://wingo-ui.com/components/file-upload.md))
- [File types](https://wingo-ui.com/components/file-types) ([Markdown](https://wingo-ui.com/components/file-types.md))
- [Model Picker](https://wingo-ui.com/components/model-picker) ([Markdown](https://wingo-ui.com/components/model-picker.md))
- [Voice Input](https://wingo-ui.com/components/voice-input) ([Markdown](https://wingo-ui.com/components/voice-input.md))
- [AI Chat](https://wingo-ui.com/components/ai-chat) ([Markdown](https://wingo-ui.com/components/ai-chat.md))
- [useKeyboardInset](https://wingo-ui.com/components/use-keyboard-inset) ([Markdown](https://wingo-ui.com/components/use-keyboard-inset.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [useScrollEdges](https://wingo-ui.com/components/use-scroll-edges) ([Markdown](https://wingo-ui.com/components/use-scroll-edges.md))
- [useShortcut](https://wingo-ui.com/components/use-shortcut) ([Markdown](https://wingo-ui.com/components/use-shortcut.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Haptics](https://wingo-ui.com/components/haptics) ([Markdown](https://wingo-ui.com/components/haptics.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultValue?` | `string` | `""` | Starting text while uncontrolled. Use value / onValueChange to control it; a rejected onSubmit restores the text through onValueChange. |
| `value?` | `string` |  | The text, controlled. Pair it with onValueChange. |
| `onValueChange?` | `(value: string) => void` |  | The 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. |
| `status?` | `"idle" \| "submitting" \| "streaming"` | `"idle"` | submitting: the button spins; streaming: it becomes Stop. Sending is blocked while not idle, typing is not. |
| `onStop?` | `() => void` |  | The Stop button and stopShortcut while status is streaming. |
| `placeholder?` | `string` | `"Ask anything"` | Also the textarea's accessible name unless aria-labelledby is passed. |
| `variant?` | `"elevated" \| "outline" \| "soft"` | `"elevated"` | elevated: a floating white panel with a soft shadow (ChatGPT); outline: a bordered field; soft: the filled well of the form fields. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Text 14 / 15 / 16px (16px on phones always, so iOS never zooms), toolbar buttons 28 / 32 / 36px, padding 8 / 10 / 12px. |
| `radius?` | `"md" \| "lg" \| "full"` | `"lg"` | md 16px, lg 24px; full is a 28px pill while the text is one line and 24px once it grows or holds attachments. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The send button, active tool chips and the focus ring's tint. Brand orange is opt-in. |
| `color?` | `string` | `""` | Any CSS color; replaces the tone. Text on the send button turns black or white by lightness. |
| `minRows?` | `number` | `1` | Height of the empty text area in rows. |
| `maxRows?` | `number` | `8` | Grows with the text up to this many rows, then scrolls (native field-sizing, no JS per keystroke). |
| `position?` | `"static" \| "sticky" \| "fixed"` | `"static"` | 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. |
| `safeArea?` | `boolean` | `true` | Pads the bottom by the home-indicator inset (at least 12px) when fixed or sticky. |
| `submitShortcut?` | `"enter" \| "mod+enter"` | `"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. |
| `maxLength?` | `number` | `0` | A soft character limit: the counter appears near it and turns red past it, and sending is blocked until the text is shorter. 0 = none. |
| `tokenLimit?` | `number` | `0` | Estimated tokens (estimateTokens): the hint reads "1,2 K / 8 K tokens" near the limit. 0 = none. |
| `showCount?` | `"auto" \| "always" \| "never"` | `"auto"` | 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. |
| `allowAttachments?` | `boolean` | `true` | The + button, drop and paste of files. |
| `accept?` | `string` | `"image/*,application/pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.json"` | Allowed types, like the input's accept attribute; checked by mime type and by extension. Defaults to DEFAULT_PROMPT_INPUT_ACCEPT. |
| `maxFiles?` | `number` | `10` | At most this many attachments; extra files are rejected with one message. |
| `maxFileSize?` | `number` | `20971520` | Bytes per file (20 MB). |
| `camera?` | `boolean` | `true` | A "Take a photo" entry (capture="environment") in the + menu on touch devices. |
| `voice?` | `boolean` | `false` | A dictation button (VoiceInput). Its transcript goes in at the caret, replacing a selection; while recording the voice pill takes the toolbar. |
| `focusShortcut?` | `string` | `""` | Focuses the box from anywhere and puts the caret at the end, e.g. "shift+escape". Empty binds nothing. |
| `stopShortcut?` | `string` | `""` | Stops 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. |
| `autoFocus?` | `boolean` | `false` | Focuses the box on mount on desktops only; a phone keyboard never pops up by surprise. |
| `disabled?` | `boolean` | `false` | Dims and blocks the box and every toolbar control. |
| `name?` | `string` | `""` | The textarea's name for forms. |
| `locale?` | `string` | `""` | Number formatting of sizes, percentages and token counts (12.5K in en-US). Empty uses the UIProvider locale, then the browser's. |
| `attachments?` | `ChatAttachment[]` |  | Controlled attachments ({ id, name, type, size, url, previewUrl, status, progress, error }). Pair it with onAttachmentsChange. |
| `defaultAttachments?` | `ChatAttachment[]` | `"[]"` | Starting attachments while uncontrolled (a restored draft). |
| `onAttachmentsChange?` | `(attachments: ChatAttachment[]) => void` |  | Every 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) => void` |  | Retry on a failed attachment the composer holds no File for (restored from a draft or added by the app). |
| `models?` | `AiModel[]` |  | Shows a ModelPicker (ghost, opening upward; a bottom sheet on phones) in the toolbar. |
| `model?` | `string` |  | The chosen model id, controlled. Uncontrolled it starts at defaultModel, else the first recommended model. |
| `defaultModel?` | `string` | `""` | Starting model id while uncontrolled. Empty starts at the first recommended model (Acme Standard in the demo). |
| `onModelChange?` | `(model: string) => void` |  | A model was picked. |
| `tools?` | `PromptTool[]` |  | 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. |
| `activeTools?` | `string[]` |  | Active tool ids, controlled. |
| `defaultActiveTools?` | `string[]` | `"[]"` | Active tool ids while uncontrolled. |
| `onActiveToolsChange?` | `(tools: string[]) => void` |  | A 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?` | `() => void` |  | ArrowUp in an empty box with the caret at the start: edit the last message. |
| `toolbarStart?` | `ReactNode` |  | Extra controls after the model picker (a project picker). |
| `toolbarEnd?` | `ReactNode` |  | Extra controls before the mic (a usage ring). |
| `labels?` | `Partial<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. |
| `modelPickerProps?` | `Partial<ModelPickerProps>` |  | Passed to the model picker: labels, shortcut, groupBy, showSpeed. |
| `voiceInputProps?` | `Partial<VoiceInputProps>` |  | Passed to the voice button: labels, lang, maxDuration, mode. |
| `classNames?` | `Partial<Record<"box" \| "textarea" \| "attachments" \| "attachment" \| "toolbar" \| "submit" \| "hint" \| "drop", string>>` |  | Extra classes for the inner parts. |
| `className?` | `string` |  | Extra classes for the root, merged last. Other props (id, aria-*, data-*) go there too. |

## Parts

### PromptInputAttachments (component)

The 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 (component)

The 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 (component)

A tool chip: an outline pill toggle with aria-pressed and an optional tooltip. Props: { label, icon?, description?, size?, ...ToggleProps }.

### readAttachments (function)

Validates 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 (constant)

The English strings, to spread and override. Type: PromptInputLabels.

### DEFAULT_PROMPT_INPUT_ACCEPT (constant)

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

## See also

- [Textarea](https://wingo-ui.com/components/textarea) ([Markdown](https://wingo-ui.com/components/textarea.md))
- [Model Picker](https://wingo-ui.com/components/model-picker) ([Markdown](https://wingo-ui.com/components/model-picker.md))
- [Voice Input](https://wingo-ui.com/components/voice-input) ([Markdown](https://wingo-ui.com/components/voice-input.md))
- [File Upload](https://wingo-ui.com/components/file-upload) ([Markdown](https://wingo-ui.com/components/file-upload.md))
- [useKeyboardInset](https://wingo-ui.com/components/use-keyboard-inset) ([Markdown](https://wingo-ui.com/components/use-keyboard-inset.md))

---

Canonical: https://wingo-ui.com/components/prompt-input
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
