AI chat input in React with a thinking state and Stop button
For an animated React AI chat input, start with Eased UI's free, MIT-licensed Composer and Thinking Bar. Composer handles growing text, attachments and the switch from Send to Stop; Thinking Bar shows reasoning progress. For a streaming message list that follows the reply until the user scrolls away, use the Pro Conversation component. They target React 19 and Tailwind CSS v4 and install as editable source through the shadcn CLI:
npx shadcn add https://eased.dev/r/composer.jsonnpx shadcn add https://eased.dev/r/thinking-bar.jsonThe rest of this guide shows how such an input works, so you can build your own or change the one you installed.
The input box is the part of an AI chat people touch most, and it has more states than a normal form field. It has to grow as someone writes a long prompt, send on Enter without breaking languages that use Enter to compose characters, turn its Send button into a Stop button while the model replies, and tell people what is happening while they wait.
This guide builds that input, plus a "Thinking 4s" label that becomes "Thought for 12s", and wires both to the Vercel AI SDK's useChat hook. It uses React 19, TypeScript, Tailwind CSS v4 and Motion (formerly Framer Motion).
npm install motion ai @ai-sdk/reactThe rest of the chat, the message bubbles and the streaming conversation list, is a separate job. For the other parts, see the collection of AI chat UI components.
The input itself doesn't depend on the AI SDK. It takes a status and two callbacks, so it works with any backend that can tell you whether it is busy.
The four states
The AI SDK's useChat reports one of four statuses, and they map cleanly onto what the input should do:
| Status | Meaning | Input behaviour |
|---|---|---|
ready | Nothing in progress | Send is enabled when there is text |
submitted | Message sent, no reply yet | Button becomes Stop |
streaming | Reply arriving | Button stays Stop |
error | Last request failed | Back to Send, so they can retry |
People can keep typing their next message while a reply streams. Only sending is blocked.
The chat input
// chat-input.tsx"use client";import { AnimatePresence, motion, useReducedMotion } from "motion/react";import { useLayoutEffect, useRef, useState, type KeyboardEvent } from "react";export type ChatStatus = "ready" | "submitted" | "streaming" | "error";type ChatInputProps = { status: ChatStatus; onSend: (text: string) => void; onStop: () => void; maxRows?: number;};const LINE = 24; // the textarea's line height in px (leading-6)const PAD = 12; // py-1.5 top and bottomexport function ChatInput({ status, onSend, onStop, maxRows = 6 }: ChatInputProps) { const [value, setValue] = useState(""); const ref = useRef<HTMLTextAreaElement>(null); const reduced = useReducedMotion(); const busy = status === "submitted" || status === "streaming"; const canSend = !busy && value.trim().length > 0; // Grow with the text up to maxRows, then scroll inside. useLayoutEffect(() => { const el = ref.current; if (!el) return; el.style.height = "auto"; const max = maxRows * LINE + PAD; el.style.height = `${Math.min(el.scrollHeight, max)}px`; el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden"; }, [value, maxRows]); function send() { if (!canSend) return; onSend(value.trim()); setValue(""); } function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) { // Enter while an IME is composing confirms the character, it must not send. if (e.nativeEvent.isComposing) return; if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } if (e.key === "Escape" && busy) { e.preventDefault(); onStop(); } } return ( <form onSubmit={(e) => { e.preventDefault(); send(); }} className="flex items-end gap-2 rounded-2xl border border-zinc-200 bg-white p-2 shadow-sm focus-within:border-zinc-400" > <textarea ref={ref} rows={1} value={value} onChange={(e) => setValue(e.target.value)} onKeyDown={onKeyDown} aria-label="Message" placeholder="Ask anything" enterKeyHint="send" className="block max-h-none flex-1 resize-none bg-transparent px-2 py-1.5 text-sm leading-6 outline-none" /> <button type={busy ? "button" : "submit"} onClick={busy ? onStop : undefined} aria-label={busy ? "Stop generating" : "Send message"} aria-disabled={!busy && !canSend} className="relative grid size-8 shrink-0 place-items-center rounded-lg bg-zinc-950 text-white aria-disabled:opacity-40" > <AnimatePresence initial={false} mode="popLayout"> <motion.svg key={busy ? "stop" : "send"} viewBox="0 0 16 16" className="size-4" aria-hidden initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.5 }} animate={{ opacity: 1, scale: 1 }} exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.5 }} transition={reduced ? { duration: 0.1 } : { type: "spring", stiffness: 520, damping: 38 }} > {busy ? ( <rect x="4.5" y="4.5" width="7" height="7" rx="1.5" fill="currentColor" /> ) : ( <path d="M8 13V3M3.5 7.5 8 3l4.5 4.5" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" /> )} </motion.svg> </AnimatePresence> </button> <span role="status" className="sr-only"> {busy ? "Generating a reply" : ""} </span> </form> );}Growing with the text
The textarea starts at one row. On every change, useLayoutEffect sets its height to auto, reads scrollHeight (the height its content needs), and sets the height to that, capped at maxRows. Past the cap, it switches on its own scrollbar.
Two details matter here:
useLayoutEffect, notuseEffect. Layout effects run before the browser paints, so the person never sees a frame at the old height with the text overflowing.- Resetting to
autofirst. Without it,scrollHeightnever gets smaller than the current height, and the box can't shrink after text is deleted.
The CSS property field-sizing: content does the same job with no JavaScript, and it is worth using once your supported browsers all have it. The script version works everywhere today.
Enter to send, without breaking IMEs
Enter sends and Shift+Enter adds a line, which is what people expect from chat apps. The check that's easy to miss is e.nativeEvent.isComposing. People typing Japanese, Chinese or Korean use an input method editor (IME) where Enter confirms the character they are composing. Without that check, the message is sent half-written.
enterKeyHint="send" labels the Enter key "Send" on phone keyboards.
Send turns into Stop
While the model is busy, the same button stops the reply. Keeping it as one button in one place means people don't hunt for the control, and its position under the thumb doesn't move.
The button changes three things together:
- its
type, fromsubmittobutton, so clicking Stop can't submit the form - its
aria-label, from "Send message" to "Stop generating" - its icon, with the outgoing icon shrinking away as the new one grows in
AnimatePresence mode="popLayout" lets the two icons overlap during the swap instead of waiting for the old one to leave. The spring (stiffness: 520, damping: 38) is fast with almost no overshoot. This is a response to a click, so it should finish in about a quarter of a second.
Escape also stops the reply while the textarea has focus.
Disabled, but still focusable
When there is no text, the Send button uses aria-disabled instead of disabled. A disabled button drops out of the tab order and screen readers may skip it, so people can't discover it exists. aria-disabled keeps it reachable and announces it as unavailable, and the canSend check inside send() makes sure it does nothing.
The thinking label
Reasoning models can take several seconds before the first word of a reply. A label that counts the seconds tells people the request is alive and roughly how long they've waited.
// thinking.tsx"use client";import { useEffect, useState } from "react";export function ThinkingLabel({ thinking }: { thinking: boolean }) { const [seconds, setSeconds] = useState(0); useEffect(() => { if (!thinking) return; const start = Date.now(); setSeconds(0); const timer = setInterval(() => setSeconds(Math.floor((Date.now() - start) / 1000)), 1000); return () => clearInterval(timer); }, [thinking]); const visible = thinking ? `Thinking ${seconds}s` : `Thought for ${seconds}s`; const spoken = `Thought for ${seconds} ${seconds === 1 ? "second" : "seconds"}`; return ( <div aria-busy={thinking || undefined} className="text-sm text-zinc-500"> <span aria-hidden className={`tabular-nums ${thinking ? "shimmer" : ""}`}> {visible} </span> {/* Announce once, when it finishes, not every second. */} <span role="status" className="sr-only"> {thinking ? "" : spoken} </span> </div> );}The counter updates once a second. It doesn't need to be smoother than that, and a 60 frames per second timer on every message would be wasted work.
The shimmer is plain CSS (for a ready-made version, see Shimmer Text). Add it to your global stylesheet:
@keyframes shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; }}@utility shimmer { background-image: linear-gradient(90deg, #71717a 40%, #09090b 50%, #71717a 60%); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: shimmer 2s linear infinite; @media (prefers-reduced-motion: reduce) { animation: none; }}The gradient is grey with a darker band in the middle, clipped to the shape of the text. Because the background is twice the width of the text, moving it from 100% to -100% sweeps the band across once per loop with no visible jump. Use literal colours here: color is transparent, so currentColor inside the gradient would be transparent too, and the text would disappear. Add a second gradient under your dark mode variant if you need one.
What screen readers hear
The visible label changes every second, so it is aria-hidden. A live region that announced "Thinking 1s, Thinking 2s, Thinking 3s" would make the page unusable. Instead, a separate role="status" region stays empty while thinking and says "Thought for 12 seconds" once, when it finishes. aria-busy marks the region as still loading in the meantime.
The input has its own status region that says "Generating a reply" when a request starts, so the start and end of the wait are both announced once.
Wiring it to useChat
// app/chat/page.tsx"use client";import { useChat } from "@ai-sdk/react";import { DefaultChatTransport } from "ai";import { ChatInput } from "./chat-input";import { ThinkingLabel } from "./thinking";export default function ChatPage() { const { messages, status, sendMessage, stop } = useChat({ transport: new DefaultChatTransport({ api: "/api/chat" }), }); return ( <main className="mx-auto flex h-dvh max-w-2xl flex-col gap-4 p-4"> <ol className="flex flex-1 flex-col gap-4 overflow-y-auto"> {messages.map((message) => ( <li key={message.id} className={message.role === "user" ? "self-end" : ""}> {message.parts.map((part, i) => { if (part.type === "reasoning") { return <ThinkingLabel key={i} thinking={part.state === "streaming"} />; } if (part.type === "text") { return ( <p key={i} className="whitespace-pre-wrap"> {part.text} </p> ); } return null; })} </li> ))} </ol> <ChatInput status={status} onSend={(text) => sendMessage({ text })} onStop={stop} /> </main> );}The AI SDK sends reasoning to the client by default when your route returns result.toUIMessageStreamResponse(). Each reasoning part has a state of "streaming" or "done", which drives the label directly: thinking while it streams, finished when it's done. If your model doesn't stream reasoning, you won't get a reasoning part, and the "Generating a reply" status plus the Stop button carry the waiting state on their own.
Reduced motion
With "Reduce motion" on in the operating system, the icon swap becomes a 100ms cross-fade with no scaling, and the shimmer stops, leaving plain grey text. The height change of the textarea isn't animated at all here, so there is nothing else to turn off.
Common mistakes
- Sending on Enter during IME composition. Check
isComposing. - A
disabledSend button. People can't tab to it to find out why it's unavailable. Usearia-disabled. - A separate Stop button somewhere else. Keep it in the Send button's place.
- Clearing the input before the send succeeds, with no way back. If you need retries, keep the last message so an error can restore it.
- Announcing the timer every second. Hide the visible counter and announce the finish once.
- Blocking typing while streaming. Only block sending.
- Measuring height in
useEffect. You get a one-frame flash of overflowing text. UseuseLayoutEffect.
FAQ
How do I make a textarea auto-resize in React?
In a useLayoutEffect that runs when the value changes, set the textarea's height to auto, then to its scrollHeight, capped at a maximum. Switch overflow-y to auto once it reaches the cap so it scrolls inside. In browsers that support it, field-sizing: content does this in CSS alone.
How do I add a Stop button to an AI chat in React?
With the Vercel AI SDK, useChat returns status and stop(). While status is submitted or streaming, render the Send button as a plain button labelled "Stop generating" that calls stop(), and change it back when status returns to ready or error.
How do I show a "Thinking" indicator for reasoning models?
Render a label for each reasoning part in the message and treat it as thinking while the part's state is "streaming". Count seconds with an interval while it streams, show "Thought for N seconds" when it's done, and announce only the final value to screen readers.
Should Enter send the message or add a new line?
In chat, Enter usually sends and Shift+Enter adds a new line. Ignore Enter while an input method editor is composing text, so people typing in Japanese, Chinese or Korean don't send half-finished words.
If you'd rather not build it
The Eased Composer and Thinking Bar are free, MIT-licensed versions of these two pieces. They add:
- a box that grows on a spring rather than jumping, up to six rows, then scrolls with soft fades at the top and bottom edges
- attachments by picking, dropping or pasting files, and hooks for
/commands and@mentions - a Send arrow that morphs into the Stop square, because both icons are drawn from the same path
- a reasoning panel that opens while the model thinks, shows its lines, and folds away a second after it finishes, with the seconds rolling on a digit counter
npx shadcn add https://eased.dev/r/composer.jsonnpx shadcn add https://eased.dev/r/thinking-bar.jsonBoth connect to useChat with one helper, fromUseChat, which maps its status, send and stop onto the components' props.