TL;DR 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.
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.
The class field-sizing-content is Tailwind v4's utility for the CSS property field-sizing: content, 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.
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). 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 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.
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:
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 for this kind of feedback.
If you want the composer itself to be the moment people remember, that is a bigger job. 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.
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.
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.
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.
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.