Every app that ships a messaging surface eventually hits the same wall: the humble <textarea> is not a chat input. It is a box that holds text. A real chat input, the kind you see in Slack, Linear, ChatGPT, or Claude, is a small, dense piece of interaction design pretending to be a single element. Underneath that calm rectangle live a dozen behaviors users now expect by reflex, and most of them have nothing to do with typing characters into a field.
If you have ever been handed a ticket that says "add a chat input text area" and assumed it was an afternoon of work, you already know how this story ends. This article walks through what a production react chat input component actually has to do, where the bare textarea gives out, and how to get the whole bundle without rebuilding the composer from scratch.
Start by listing the behaviors people expect but never articulate. A genuine composer is judged by the absence of friction, so each missing piece reads as a bug rather than a missing feature.
None of these are exotic. Together they are why "just use a textarea" quietly becomes a multi-week project.
A native <textarea> stores a single string. That is its strength and its ceiling. The moment you want a mention to render as a colored pill that the user cannot accidentally split in half, you have left the textarea's world entirely. There is no way to embed a non-editable, styled token inside a plain text field. You also cannot show inline formatting, such as a live preview of **bold** as you type, because the textarea has exactly one font and zero structure.
So teams reach for one of two escape hatches, and both have a cost:
There is a third option that sits between "lonely textarea" and "document framework": a focused contentEditable surface engineered specifically for composers. This is exactly the niche Prompt Area fills. It is a production-grade contentEditable input purpose-built for prompt-style and chat inputs, with no ProseMirror, Slate, or Lexical underneath, just React and your stack, with zero extra editor dependencies.
The mental model is what makes it pleasant to work with. Instead of a string, your value is a typed array of segments. A run of plain text is a TextSegment; a resolved mention or command is a ChipSegment carrying its trigger, value, display text, and any structured data you attached. That data model is the thing the bare textarea could never give you, and it is what turns "parse the message afterward" into "read the chips you already have."
The trigger system is the heart of a composer. In Prompt Area you register characters (@, /, #, or anything else), and each one either opens a dropdown or fires a callback. When the user picks an item, it resolves into an immutable chip: a non-editable pill the caret skips over and Backspace removes whole. There are ready-made presets (mentionTrigger, commandTrigger, hashtagTrigger, callbackTrigger) so the common cases are a few lines, not a subsystem.
The behaviors from the checklist above ship in the box: auto-grow that expands on focus and shrinks on blur, Enter-to-send with Shift+Enter for newlines, inline markdown preview for **bold**/*italic*, URL auto-linking with Cmd/Ctrl+Click to open, list auto-formatting from - with Tab/Shift+Tab to indent, undo/redo with debounced snapshots, copy/paste that preserves chip data internally and auto-resolves triggers on external paste, proper IME composition for CJK, ARIA labels, and file/image attachments with thumbnails and loading states. You also get an imperative ref API (focus(), insertChip(), getPlainText(), clear()) for the moments you need to poke the input from outside.
The surface area is deliberately tiny: one component, PromptArea, and one hook, usePromptAreaState(). The hook owns the controlled Segment[] value; you decide what happens on submit. Here is a minimal chat input with mentions and slash commands:
import { PromptArea, usePromptAreaState, mentionTrigger, commandTrigger, segmentsToPlainText } from 'prompt-area' function ChatInput({ onSend }) { const state = usePromptAreaState() const triggers = [ mentionTrigger({ items: async (q) => searchUsers(q) }), commandTrigger({ items: [{ value: 'summarize', label: '/summarize' }] }), ] return ( <PromptArea state={state} triggers={triggers} placeholder={['Message your team…', 'Type / for commands', 'Use @ to mention']} onSubmit={(segments) => { if (segmentsToPlainText(segments).trim()) { onSend(segments) state.clear() } }} /> ) }
A few things worth noticing. The placeholder takes an array of strings, which animates between rotating prompts, a small touch that makes an empty composer feel alive. The onSubmit handler receives the full Segment[], so you can either flatten it with segmentsToPlainText() for a simple send, or inspect the chips with helpers like getChipsByTrigger() when you need structured data. And because the value is controlled by the hook, clearing the field after send is a single state.clear() call rather than a dance with refs and defaults.
How you adopt a composer matters as much as what it does. Some teams want a dependency they update with a lockfile; others want the source in their repo so they can bend it freely. Prompt Area ships both from one source:
If you want to feel the difference before committing, there is a live in-browser demo at prompt-area.com/docs/try-it-live. It is a full Vite + React app where you can type, trigger chips, paste, and watch auto-grow in action.
To be fair about scope: if you are genuinely building a document editor, with multi-page content, tables, collaborative cursors, and a serialized rich document, then a framework like Tiptap or Lexical is the right tool and you should reach for it. The point is that most teams who install one of those are not building a document editor. They are building a chat box and paying the document-editor tax for it: extra dependencies, a schema to learn, and behaviors like auto-grow and mention chips that you still have to assemble yourself.
So the honest rule of thumb looks like this:
The chat input is one of those components that looks small in the mockup and turns out to be a product surface of its own. The trick is recognizing that early, before you have hand-rolled caret math and a fake highlight layer, and choosing a tool whose physics match a composer rather than a page. Get the foundation right and the rest of your messaging UI, from the message list to streaming responses, has a stable place to stand.