# React Prompt Input: Enter, IME, Files, Stop and Voice

> A React prompt input that handles Enter vs Shift+Enter, IME composition, uploads with progress, a Stop button, dictation and the iPhone keyboard. With code.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Building UI with AI agents](https://wingo-ui.com/blog/category/ai-agents)
- Reading time: 10 min
- Canonical: https://wingo-ui.com/blog/react-prompt-input

## TL;DR

A good React prompt input sends on Enter and adds a line on Shift+Enter with a hardware keyboard, ignores Enter while an IME is composing, and inserts a new line on touch keyboards where the send button does the sending. It validates and uploads files with progress and abort, turns Send into Stop while the answer streams, restores the text when a send fails, and dictates at the caret. On a phone it uses 16px text, stays above the on-screen keyboard and keeps the keyboard open after a send.

A React prompt input is the box at the bottom of every AI chat, and the first version always looks done. A textarea, an Enter handler and a send button work on your laptop. Then someone typing Japanese sends half a sentence when they confirm a word, a phone user cannot add a second line, a 30 MB PDF fails with no message, the Stop button is missing while a long answer streams, and on iOS the box slides behind the keyboard. This guide walks through each of those details, with the code we ship in the Wingo UI [Prompt Input](https://wingo-ui.com/components/prompt-input). We build that library, so we are not neutral; outside facts link to their sources.

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

## What should Enter do in a React prompt input?

On a hardware keyboard, Enter sends and Shift+Enter adds a line. MUI's [chat composer docs](https://mui.com/x/react-chat/basics/composer/) and Vercel's [AI Elements Prompt Input](https://elements.ai-sdk.dev/components/prompt-input) both document that default (as of October 2026). Three exceptions matter:

- **Touch keyboards.** A phone keyboard has no Shift+Enter. If Enter sends there, nobody can write a list. Prompt Input checks `(pointer: coarse)` and lets Enter insert a new line on touch screens, so the round button sends.
- **Long-form prompts.** With `submitShortcut="mod+enter"`, Enter is a new line and Cmd/Ctrl+Enter sends. Cmd/Ctrl+Enter sends in both modes.
- **Key repeat.** A held Enter fires keydown repeatedly, so the handler ignores `event.repeat`.

The [enterkeyhint attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/enterkeyhint) only changes the label or icon of the Enter key, never its behavior, so it has to match what your handler does. Prompt Input sets `enterKeyHint="send"` when Enter sends and `"enter"` on touch, where it makes a line. A hidden "Shift + Enter for a new line" hint, linked through `aria-describedby`, tells screen reader users the rule.

## How do you stop Enter from sending during IME composition?

Return early from the key handler while a composition is open. Japanese, Chinese and Korean input methods use Enter to confirm a conversion, and that Enter reaches your `keydown` handler. MDN's [keydown reference](https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event) explains why `isComposing` alone is not enough: on the keydown that opens or closes the IME it can be false, while `keyCode` is still 229. So the guard checks both, plus the `Process` key some browsers report:

```tsx
import type { KeyboardEvent } from "react";

const isCoarse = () => window.matchMedia("(pointer: coarse)").matches;

export function onPromptKeyDown(event: KeyboardEvent<HTMLTextAreaElement>, submit: () => void) {
  // enter confirms an ime composition and never sends
  if (event.nativeEvent.isComposing || event.key === "Process" || event.keyCode === 229) return;
  if (event.key !== "Enter") return;
  const mod = event.metaKey || event.ctrlKey;
  const plain = !mod && !event.shiftKey && !event.altKey;
  // touch keyboards have no shift+enter, so a plain enter makes a line there
  if (mod || (plain && !isCoarse())) {
    event.preventDefault();
    if (!event.repeat) submit();
  }
}
```

Wire it up with `onKeyDown={(event) => onPromptKeyDown(event, send)}`, then test it with a Japanese or Pinyin input method before you ship. On macOS, dead keys for accents (Option+E, then E) open a short composition too, so the same guard covers them. TypeScript marks `keyCode` as deprecated; keep it anyway, since it is the only signal on those edge keydowns.

## How should prompt input attachments work?

Send every way a file gets in through one validator, show progress per file, and let people cancel. People add files three ways:

1. **The + button.** It opens Upload files, and on touch devices Take a photo (`capture="environment"`, turn it off with `camera={false}`).
2. **Drop.** Files dropped anywhere on the composer are accepted, with a dashed overlay that says where they land.
3. **Paste.** A pasted screenshot becomes an attachment. Pasted text stays text.

All three go through `readAttachments`, which checks the type by mime type and extension against `accept`, the size against `maxFileSize` (20 MB by default) and the count against `maxFiles` (10). Rejected files get one clear message ("Invoice.pdf is larger than 20 MB") instead of disappearing.

Then the upload. Pass `onUpload(file, { signal, onProgress })` and each attachment shows a progress ring, a failure shows Retry, and removing a file aborts its request through the signal. Call `onProgress` as often as you like: the composer renders at most once per 100ms per file. One pitfall: `fetch` gives you no upload progress, so a real progress ring needs `XMLHttpRequest`:

```ts
// lib/upload-file.ts
export function uploadFile(file: File, { signal, onProgress }: { signal: AbortSignal; onProgress: (fraction: number) => void }) {
  return new Promise<{ url: string }>((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("POST", "/api/files");
    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable) onProgress(event.loaded / event.total);
    };
    xhr.onload = () => (xhr.status < 300 ? resolve(JSON.parse(xhr.responseText)) : reject(new Error("Upload failed")));
    xhr.onerror = () => reject(new Error("Upload failed"));
    signal.addEventListener(
      "abort",
      () => {
        xhr.abort();
        reject(new DOMException("Aborted", "AbortError"));
      },
      { once: true },
    );
    const body = new FormData();
    body.append("file", file);
    xhr.send(body);
  });
}
```

Sending waits until no file is still uploading, and failed files are left out of the message. Without `onUpload`, attachments stay local and you upload them yourself in `onSubmit`. Resolve with a URL string or `{ url }` and the attachment's `url` is set for you. Keyboard users get one more shortcut: Backspace with the caret at the very start of the box focuses the last attachment's remove button, and Backspace again removes it.

## How do you add a Stop button while the answer streams?

Turn Send into Stop for as long as the answer streams. Prompt Input takes a `status` of `idle`, `submitting` or `streaming`. While submitting, the button spins; while streaming, it is a Stop square that calls `onStop`. Sending is blocked until the status is idle again, but typing is not, so people can write the next question while the answer streams.

One pitfall sits on the server. Aborting the `fetch` closes the connection, but the model call in your route handler can keep running, and using tokens you pay for, unless it listens too. Pass `request.signal` from the route handler's `Request` to your model SDK or upstream `fetch`, so Stop ends the generation and not only the download.

Set `stopShortcut="escape"` and Escape stops the answer too. A bare key like Escape only works while focus is in the composer, so a dialog on the page keeps its own Escape, and an open menu or model list closes before the answer stops.

Two more details:

- **The box clears at once.** The text and files leave the composer the moment you send, and focus stays in the box. If `onSubmit` returns a promise that rejects, the text and files come back (unless the person has already typed something new) and an error is announced.
- **ArrowUp edits the last message.** With `onEditLast`, ArrowUp in an empty box calls it, so people can fix a typo in the prompt they just sent.

## How do dictation and voice input fit in the composer?

Pass `voice` and the [Voice Input](https://wingo-ui.com/components/voice-input) mic joins the toolbar; while recording, a pill with a live waveform takes it over. The words land where the caret was, replacing a selection, so someone can dictate into the middle of a prompt they typed. Finished words go into the text; words the browser is still unsure of stay muted in the pill. Cancel puts the text back exactly as it was.

The live words come from the Web Speech API, which MDN lists as [limited availability](https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition), and in Chrome the audio is sent to a web service for recognition. Where the API is missing, the audio still records and the transcript stays empty. To transcribe on your server or send voice messages, pass `onVoice` as well and you get `{ blob, mimeType, durationMs, transcript }` instead. Language, length and push-to-talk go through `voiceInputProps={{ lang: "en-US", maxDuration: 60, mode: "hold" }}` (the default `maxDuration` is 120 seconds). The fallbacks and the waveform are covered in the [React speech to text input guide](https://wingo-ui.com/blog/react-speech-to-text-input).

## How do you keep an AI chat composer usable on a phone?

Hold the text at 16px, keep the keyboard open after a send and lift the box above the keyboard. These bugs only show up on a real phone, so Prompt Input handles them:

- **No zoom on focus.** iOS Safari zooms into inputs with text under 16px. The text is 16px on phones at every `size`; the `sm` and `md` sizes drop to 14 and 15px only from 768px up.
- **The keyboard stays open.** Tapping Send would normally blur the textarea and close the keyboard. The button calls `preventDefault` on pointerdown while the box has focus, so the keyboard stays up for the next message.
- **Above the keyboard.** With `position="fixed"`, the composer reads the visual viewport through `useKeyboardInset` and lifts itself by the covered height with a motion value, no re-render per frame. It writes its height to `--prompt-input-offset` on `<html>`, so your thread can pad its content above it. The full story is in [iOS Safari keyboard and position fixed](https://wingo-ui.com/blog/ios-safari-keyboard-position-fixed).
- **Room for the text.** In a composer narrower than 512px, tool chips move into the + menu as checkbox rows (a bottom sheet on phones), and only active tools stay visible. The check reads the composer's own width through a `ResizeObserver`, not the viewport, so a composer in a narrow desktop side panel gets the same treatment. The [Model Picker](https://wingo-ui.com/components/model-picker) opens as a bottom sheet with 64px rows below 768px.
- **It grows, then scrolls.** Up to `maxRows` (8 by default), with CSS `field-sizing: content` where supported and one measurement per change where it is not.

> Live demo (Prompt Input): Type a long prompt, then press Enter to send and Escape to stop the streaming answer. Drop a file or tap the mic to dictate at the caret. Try it at [Prompt Input](https://wingo-ui.com/components/prompt-input) and install it with `npx wingo-ui@latest add prompt-input`.

## Where should prompt suggestion chips go?

Above the composer on the empty screen, and as follow-ups after an answer. Prompt suggestion chips save typing on a phone and show what the assistant can do. [Suggestion Chips](https://wingo-ui.com/components/suggestion-chips) renders them as pills, cards or rows (`variant` of `chip`, `card` or `list`), and keeps no selection: `onSelect` hands you the suggestion, and you decide whether it sends at once or fills the box for editing.

Filling the box suits prompts that need a detail, like a client name: give the suggestion a `prompt` that ends where the person types. Below 768px the pills sit in one swipeable row that runs to the screen edges, with 44px targets, and the whole group is one Tab stop with arrow keys inside. Pass `disabled` while a reply streams: picks are blocked but focus stays where it was.

> Live demo (Suggestion Chips): Tap a suggestion: the readout shows what onSelect receives, which your app sends or inserts. Try it at [Suggestion Chips](https://wingo-ui.com/components/suggestion-chips) and install it with `npx wingo-ui@latest add suggestion-chips`.

## What does a complete React AI prompt input look like?

A full composer for the Next.js App Router: a model picker, a tool, suggestion chips that fill the box, upload progress and a Stop that keeps the partial answer.

```tsx
// app/assistant/composer.tsx
"use client";

import { useRef, useState } from "react";
import { Gem, Globe, Mail, Receipt, Sparkles } from "lucide-react";
import { PromptInput, type AiModel, type PromptInputMessage, type PromptInputStatus, type PromptTool } from "@/components/ui/prompt-input";
import { SuggestionChips, type ChatSuggestion } from "@/components/ui/suggestion-chips";
import { uploadFile } from "@/lib/upload-file";

const MODELS: AiModel[] = [
  { id: "standard", name: "Acme Standard", description: "Balanced, for everyday work", icon: <Sparkles />, recommended: true },
  { id: "pro", name: "Acme Pro", description: "Deep reasoning for reports", icon: <Gem /> },
];

const TOOLS: PromptTool[] = [{ id: "web", label: "Web search", icon: <Globe />, description: "Looks for current sources online" }];

const SUGGESTIONS: ChatSuggestion[] = [
  { id: "overdue", label: "Summarize overdue invoices", icon: <Receipt /> },
  { id: "follow-up", label: "Write a follow-up email", prompt: "Write a follow-up email to ", icon: <Mail /> },
];

export function Composer({ onChunk }: { onChunk: (text: string) => void }) {
  const [draft, setDraft] = useState("");
  const [status, setStatus] = useState<PromptInputStatus>("idle");
  const abort = useRef<AbortController | null>(null);

  async function send(message: PromptInputMessage) {
    const controller = new AbortController();
    abort.current = controller;
    setStatus("submitting");
    try {
      const response = await fetch("/api/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(message),
        signal: controller.signal,
      });
      if (!response.ok || !response.body) throw new Error("The request failed");
      setStatus("streaming");
      const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
      for (;;) {
        const { done, value } = await reader.read();
        if (done) break;
        onChunk(value);
      }
    } catch (error) {
      // stopped: the partial answer stays; a failure rejects, so the box gets the text and files back
      if (!controller.signal.aborted) throw error;
    } finally {
      setStatus("idle");
    }
  }

  return (
    <div className="flex flex-col gap-3">
      {draft === "" && status === "idle" && (
        <SuggestionChips suggestions={SUGGESTIONS} size="sm" onSelect={(s) => setDraft(s.prompt ?? s.label)} />
      )}
      <PromptInput
        value={draft}
        onValueChange={setDraft}
        onSubmit={send}
        status={status}
        onStop={() => abort.current?.abort()}
        stopShortcut="escape"
        models={MODELS}
        tools={TOOLS}
        onUpload={uploadFile}
        voice
        tokenLimit={8000}
      />
    </div>
  );
}
```

`message` carries `{ text, attachments, model, tools }`, with the text trimmed and only ready (or local) attachments. `tokenLimit` shows a counter past 80% of 8,000 estimated tokens and blocks sending past it. The estimate comes from `estimateTokens` in `lib/ai-chat`: one token per CJK character and one per four other characters, close enough for a warning but no substitute for your model's tokenizer on the server. For the thread around this composer, with auto-scroll and streaming markdown, read the [React AI chat UI guide](https://wingo-ui.com/blog/react-ai-chat-ui).

## When is another prompt input the better choice?

When your stack already decides it. If your app runs on the AI SDK, Vercel's [AI Elements](https://github.com/vercel/ai-elements) is free, Apache 2.0 licensed and built on shadcn/ui, with a Prompt Input made of parts you compose yourself (`PromptInputTextarea`, `PromptInputTools`, `PromptInputSubmit`). Its README says, as of October 2026, that the project will be deprecated at the end of November 2026 and replaced by [AI Elements from shadcn/ui](https://ui.shadcn.com/docs/ai-elements), so new projects should plan for that move. Our [AI Elements alternatives comparison](https://wingo-ui.com/blog/ai-elements-alternative) covers the other kits. If you use MUI, its [X Chat composer](https://mui.com/x/react-chat/basics/composer/) handles Enter and IME composition and is labeled a preview as of October 2026. Wingo UI's composer fits when you want one component with the phone behavior, uploads and screen reader announcements already done, and you own the transport (any `fetch`, SSE or WebSocket call in `onSubmit`).

## What should you try next?

Try `submitShortcut`, `position` and `voice` in the [Prompt Input playground](https://wingo-ui.com/components/prompt-input), then install it with `npx wingo-ui@latest add prompt-input`. It and the other components here are part of [Wingo UI Pro](https://wingo-ui.com/pricing). For connecting a coding agent to these components, start with the [component library MCP guide](https://wingo-ui.com/blog/component-library-mcp-guide), and find more posts in [Building UI with AI agents](https://wingo-ui.com/blog/category/ai-agents).

## Components in this post

- [Prompt Input](https://wingo-ui.com/components/prompt-input) (Pro): 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. Install: `npx wingo-ui@latest add prompt-input`
- [Suggestion Chips](https://wingo-ui.com/components/suggestion-chips) (Pro): React suggestion chips for AI chat: prompts people tap instead of typing, as pills under a composer, welcome cards or follow-up rows. Install: `npx wingo-ui@latest add suggestion-chips`
- [Voice Input](https://wingo-ui.com/components/voice-input) (Pro): A React voice input: a mic that grows into a recording pill with a live waveform and transcript, a full-width bar, or a voice orb. Install: `npx wingo-ui@latest add voice-input`
- [Model Picker](https://wingo-ui.com/components/model-picker) (Pro): A React AI model picker for chat apps: a quiet trigger that opens a short grouped list of models, each with one line on what it is for. Install: `npx wingo-ui@latest add model-picker`

## FAQ

### How do I make Enter send and Shift+Enter add a new line in a React textarea?

Handle onKeyDown: when the key is Enter without Shift, call preventDefault and submit; otherwise let the browser insert the line. Return early first when event.nativeEvent.isComposing is true or keyCode is 229, so Enter that confirms an IME conversion never sends.

### Should Enter send the message on a phone?

No. Touch keyboards have no Shift+Enter, so if Enter sends, people cannot write a second line. Let Enter insert a new line on coarse pointers and give the send button the job, with enterkeyhint set to enter.

### How do I show upload progress for prompt input attachments?

fetch does not report upload progress, so upload with XMLHttpRequest and read xhr.upload.onprogress, then pass the fraction to the composer. Abort the request when the person removes the file, and offer Retry when it fails.

### How do I add a Stop button to an AI chat composer in React?

Keep a status of idle, submitting or streaming next to the composer, render Stop while it is streaming, and abort the request with an AbortController when it is pressed. Keep the partial answer in the thread and set the status back to idle.

### Is the Wingo UI Prompt Input free?

No. Prompt Input, Suggestion Chips, Voice Input and Model Picker are part of Wingo UI Pro. Pro is $8 a month, $80 a year or $150 once (as of October 2026), and the 75 free items include the Textarea, Button and Tooltip it builds on.

---

Source: https://wingo-ui.com/blog/react-prompt-input
