# Build an AI Chat Input in React and Tailwind (Enter to Send, Stop, Auto-Grow)

> An AI chat input in React needs a textarea that grows with the prompt, Enter to send with Shift+Enter for new lines, no send while an IME is composing, and a Stop button that aborts the stream. The ChatComposer below does all of that with Tailwind CSS v4 and no animation library.

- URL: https://qodesign.dev/blog/ai-chat-input-react-tailwind
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-20
- Updated: 2026-10-21
- Category: AI-built UI
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

An AI chat input in React needs five things a plain `<input>` doesn't give you: a textarea that grows with the prompt, Enter to send with Shift+Enter for a new line, no accidental send while someone is typing Japanese or Chinese through an input method, a Stop button that cancels the request mid-stream, and a label screen readers can announce. The component below does all five in about 100 lines of React and Tailwind CSS v4, with no animation library, and I tested each behavior in headless Chromium before writing this.

If you build with v0, Cursor or Lovable, this is usually the first component the generator gets almost right. It renders a nice box, then sends half-typed prompts, never stops a long answer, and stays one line tall while you paste a stack trace into it.

## What should an AI chat input do?

Here is the checklist I use, in the order users notice problems:

1. **Grow with the text** up to a maximum height, then scroll inside.
2. **Enter sends, Shift+Enter adds a line.** On phones, the keyboard shows a Send key.
3. **Ignore Enter while an IME is composing.** People typing in Japanese, Chinese or Korean press Enter to confirm a word, not to send.
4. **Stop while streaming.** The send button turns into a stop button that aborts the fetch.
5. **Accessible by default.** A real `<form>`, a label, and button names that say what they do.

## The ChatComposer component

Drop this into `components/ChatComposer.tsx`. It works in the Next.js App Router (it's a client component) and in Vite.

```tsx
"use client";

import { useLayoutEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react";

type Props = {
  onSend: (prompt: string, signal: AbortSignal) => Promise<void>;
  placeholder?: string;
};

const MAX_HEIGHT = 200; // px, then the textarea scrolls

export function ChatComposer({ onSend, placeholder = "Ask anything" }: Props) {
  const [value, setValue] = useState("");
  const [busy, setBusy] = useState(false);
  const formRef = useRef<HTMLFormElement>(null);
  const inputRef = useRef<HTMLTextAreaElement>(null);
  const abortRef = useRef<AbortController | null>(null);
  const empty = value.trim() === "";

  // Auto-grow fallback for browsers without `field-sizing: content`
  useLayoutEffect(() => {
    const el = inputRef.current;
    if (!el || CSS.supports("field-sizing", "content")) return;
    el.style.height = "auto";
    el.style.height = `${Math.min(el.scrollHeight, MAX_HEIGHT)}px`;
  }, [value]);

  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    if (busy || empty) return;
    const prompt = value.trim();
    const controller = new AbortController();
    abortRef.current = controller;
    setValue("");
    setBusy(true);
    try {
      await onSend(prompt, controller.signal);
    } catch (err) {
      if ((err as Error).name !== "AbortError") throw err;
    } finally {
      abortRef.current = null;
      setBusy(false);
      inputRef.current?.focus();
    }
  }

  function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
    if (e.key !== "Enter" || e.shiftKey) return;
    // Enter that confirms an IME candidate (Japanese, Chinese, Korean) must not send
    if (e.nativeEvent.isComposing || e.keyCode === 229) return;
    e.preventDefault();
    formRef.current?.requestSubmit();
  }

  return (
    <form
      ref={formRef}
      onSubmit={handleSubmit}
      className="flex items-end gap-2 rounded-3xl border border-white/10 bg-zinc-900 p-2 shadow-lg transition-colors focus-within:border-lime-300/60"
    >
      <label htmlFor="chat-prompt" className="sr-only">
        Message
      </label>
      <textarea
        id="chat-prompt"
        ref={inputRef}
        rows={1}
        value={value}
        onChange={(e) => setValue(e.target.value)}
        onKeyDown={handleKeyDown}
        placeholder={placeholder}
        enterKeyHint="send"
        className="field-sizing-content max-h-[200px] min-h-11 flex-1 resize-none bg-transparent px-3 py-2.5 text-base text-zinc-100 placeholder:text-zinc-500 focus:outline-none"
      />
      {busy ? (
        <button
          type="button"
          onClick={() => abortRef.current?.abort()}
          aria-label="Stop generating"
          className="grid size-11 shrink-0 place-items-center rounded-full bg-zinc-100 text-zinc-900 motion-safe:animate-pulse"
        >
          <span className="size-3.5 rounded-sm bg-current" />
        </button>
      ) : (
        <button
          type="submit"
          disabled={empty}
          aria-label="Send message"
          className="grid size-11 shrink-0 place-items-center rounded-full bg-lime-300 text-zinc-950 transition-[transform,opacity] duration-200 ease-out disabled:opacity-40 motion-safe:disabled:scale-90 motion-safe:enabled:active:scale-90"
        >
          <svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
            <path d="M12 19V5M5 12l7-7 7 7" />
          </svg>
        </button>
      )}
    </form>
  );
}
```

A few lines carry most of the behavior, so they're worth a closer look.

## How does the textarea auto-grow?

The class `field-sizing-content` is Tailwind v4's utility for the CSS property [`field-sizing: content`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/field-sizing), which lets a textarea size itself to its content. `max-h-[200px]` caps it, and after that the textarea scrolls. No JavaScript runs in browsers that support it.

MDN still marks `field-sizing` as experimental, so the `useLayoutEffect` is a fallback. It checks `CSS.supports("field-sizing", "content")` and, where that returns false, sets the height to `scrollHeight` on every change. Resetting to `auto` first is what lets the box shrink again after you delete lines. I tested both paths: with native support, three lines took the textarea from 44 px to 92 px; with `CSS.supports` forced to false, four lines grew it to 116 px through the inline style. Thirty lines stopped at 200 px in both.

## Why check isComposing before sending on Enter?

When someone types through an input method editor, Enter confirms the selected characters. If your keydown handler sends on every Enter, a Japanese user sends a half-written prompt on the first word. `e.nativeEvent.isComposing` is true during composition ([MDN on isComposing](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/isComposing)). The `keyCode === 229` check is a belt-and-braces guard for browsers that report the confirming keystroke with that code instead. `keyCode` is deprecated, and TypeScript still accepts it, so it costs nothing to keep.

The handler calls `formRef.current?.requestSubmit()` instead of calling the send function directly. [`requestSubmit`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/requestSubmit) fires the form's submit event the same way a click on the submit button does, so the empty check and the busy check live in one place: `handleSubmit`.

## How do you stop a streaming response?

Each send creates an `AbortController` and passes its `signal` to your `onSend` function. While `busy` is true, the send button is replaced by a Stop button that calls `abort()`. The `fetch` rejects with an `AbortError`, which the component swallows on purpose, because stopping isn't an error from the user's point of view. Anything else is rethrown so you still see real failures.

Here is a parent that streams text from a route handler:

```tsx
"use client";

import { useState } from "react";
import { ChatComposer } from "./ChatComposer";

type Message = { role: "user" | "assistant"; text: string };

export default function Chat() {
  const [messages, setMessages] = useState<Message[]>([]);

  async function send(prompt: string, signal: AbortSignal) {
    setMessages((m) => [...m, { role: "user", text: prompt }, { role: "assistant", text: "" }]);
    const res = await fetch("/api/chat", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ prompt }),
      signal,
    });
    if (!res.ok || !res.body) throw new Error(`chat ${res.status}`);
    const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
    for (;;) {
      const { value, done } = await reader.read();
      if (done) break;
      setMessages((m) => {
        const last = m[m.length - 1];
        return [...m.slice(0, -1), { ...last, text: last.text + value }];
      });
    }
  }

  return (
    <div className="mx-auto flex h-dvh max-w-2xl flex-col gap-4 p-4">
      <ol aria-live="polite" className="flex-1 space-y-3 overflow-y-auto">
        {messages.map((m, i) => (
          <li key={i} className={m.role === "user" ? "ml-auto w-fit max-w-[80%] rounded-2xl bg-zinc-800 px-4 py-2" : "text-zinc-200"}>
            {m.text}
          </li>
        ))}
      </ol>
      <ChatComposer onSend={send} />
    </div>
  );
}
```

And a route handler that streams plain text. Swap the fake words for your model call, and keep returning a stream:

```ts
// app/api/chat/route.ts
export async function POST(req: Request) {
  const { prompt } = (await req.json()) as { prompt?: string };
  if (!prompt?.trim()) return new Response("Empty prompt", { status: 400 });

  // Replace this with your model call. Keep returning a stream of text.
  const words = `You asked: ${prompt}`.split(" ");
  const encoder = new TextEncoder();
  const stream = new ReadableStream<Uint8Array>({
    async start(controller) {
      for (const word of words) {
        if (req.signal.aborted) break;
        controller.enqueue(encoder.encode(word + " "));
        await new Promise((r) => setTimeout(r, 40));
      }
      controller.close();
    },
  });

  return new Response(stream, { headers: { "content-type": "text/plain; charset=utf-8" } });
}
```

`aria-live="polite"` on the message list lets screen readers announce the answer as it arrives without interrupting the user.

## Where does the animation come in?

The motion here is small and only uses transforms and opacity: the send button fades and shrinks a little while the field is empty, presses in on click, and the stop button pulses while the answer streams. The shrink, the press and the pulse sit behind `motion-safe:`, so people who turned on reduced motion get only the fade. I wrote about [when CSS is enough and when you need Motion](/blog/framer-motion-vs-css-micro-interactions) for this kind of feedback.

If you want the composer itself to be the moment people remember, that is a bigger job. [Prompt Bloom](/components/prompt-bloom) is the version I sell: the prompt lifts into a message bubble on send, the composer shrinks into a Thinking pill, and the answer card grows out of it, streams word by word and builds a small bar chart. It has an `onSubmit` prop for your model call and Enter to send, and it ships as plain HTML/CSS/JS plus a React + TypeScript + Tailwind version, with no animation library.

![Prompt Bloom, an animated AI chat composer, with the answer card open above the input](/previews/prompt-bloom.jpg)

## What I tested

I ran the component in headless Chromium with Playwright and checked:

- The send button is disabled and Enter does nothing while the field is empty.
- Shift+Enter adds new lines; Enter sends the whole multi-line prompt and clears the field.
- A keydown with `isComposing: true` does not send.
- The streamed answer appears in the list, and focus returns to the textarea.
- During a slow request the Stop button shows, aborts the request, and the Send button comes back.
- The auto-grow fallback works when `field-sizing` support is switched off.
- `tsc --strict` passes on all three files, and the route handler returns a 4-chunk stream for a 4-word answer and 400 for an empty prompt.

To try the pattern on a real animated component before buying anything, [/free](/free) gives you one qodesign component with its full source and AI prompt.

## FAQ

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

Handle `onKeyDown`: if the key is Enter and Shift isn't held, call `preventDefault()` and submit the form with `form.requestSubmit()`. Skip it when `e.nativeEvent.isComposing` is true so IME users can confirm characters with Enter.

### How do I auto-resize a textarea in Tailwind?

In Tailwind CSS v4, add `field-sizing-content` with a `max-h-*` cap. Browsers without `field-sizing` support need a small fallback that sets `style.height` to `scrollHeight` on change, after resetting it to `auto`.

### How do I add a stop button to a streaming chat in Next.js?

Create an `AbortController` per request, pass `controller.signal` to `fetch`, and call `controller.abort()` from the stop button. Catch the `AbortError` so stopping doesn't show as a failure.

### Do I need Framer Motion for an animated chat input?

No. The press, fade and pulse here are Tailwind transitions. A library starts to pay off when elements need to animate as they enter or leave the DOM, like a message bubble flying up from the input.
