An AI chat interface is four pieces: a prompt input, a scrolling thread, a set of states for the model's turn (waiting, streaming, stopped, failed), and the plumbing that keeps the first two honest while text arrives a token at a time. The first three you can take from a catalogue this afternoon. The fourth is where the week goes, and no component ships it for you.
There are 62 public components on 21st under ai chat, and most of them are the input. That ratio is the honest shape of the problem: the composer is the part that is pleasant to design, and the thread is the part that is hard to get right.
The input
The most saved is Animated AI Chat by Jatin Yadav at 1,404 bookmarks, a composer with command suggestions and attachment affordances already laid out. v0 AI Chat by kokonutd (1,140) is the tighter shape, one field with the controls tucked underneath.
Two of them are deliberate restatements of tools people already have muscle memory for: Chatgpt Prompt Input by Hossain Jahed (711) and Claude-style Chat Input by Saifullah Channa (693). Borrowing a familiar composer is not lazy: it is the one control where people arrive with expectations already formed.
For a plainer base to build on: AI Chat Input by preetsuthar17 (491), Ai chat input by Hossain Jahed (391), and Chat Input by simple-ai (237). Prompt Input dynamic grow by zochory (307) is the auto-growing field on its own, and Multimodal AI Chat Input by Erik X (179) is the version that carries files and images into a turn.




The thread
Fewer people publish this half, which tells you something. Message Dock by Isaiah (729) is the floating-panel treatment. Chat Message List by jakobhoeg (228) is the plain scrolling transcript. AI Chat by beratberkay (251) gives you both halves together, a reasonable way to see the shape before you commit.
The states
The model's turn has more states than done. Message loading by jakobhoeg (359) is the indicator for the gap between send and first token, which is the longest second in the whole interaction. AI Chat Image Generation by gonzalo chalé (527) covers the turn that resolves to an image rather than text, placeholder included.
The parts nobody warns you about
Everything above is layout. Here is what is left, roughly in the order it bites.
Streaming without re-rendering the thread. The obvious implementation keeps the conversation in one state object and appends each token to the last message. At 40 tokens a second that re-renders every message 40 times a second, and somewhere around message thirty the input starts dropping keystrokes. Completed messages are immutable, so memoise them by id and let React skip them. Keep the in-flight message in its own component with its own state, and batch arriving chunks on an animation frame instead of calling a setter per chunk.
Autoscroll that respects the reader. Follow the newest text only while the container is pinned to the bottom, which means measuring scrollHeight - scrollTop - clientHeight against a small threshold rather than assuming. The moment someone scrolls up, stop, and offer a jump-to-latest control instead. The bug everyone ships first is that programmatic scrolling fires the same event a person does, so the code reads its own scroll as intent to leave. Separate them with a flag around the programmatic call, or treat wheel and touchmove as the signal for real intent.
A field that grows and then stops. Reset the textarea height to auto, set it to scrollHeight, cap it with a max height, and let overflow take over past the cap. Do it in a layout effect, not a passive one, or the field visibly jumps a frame after each keystroke. Chromium now does the whole thing in CSS with field-sizing: content plus a max height, worth using with the JS as fallback. And handle composition: on a Japanese or Chinese IME, Enter confirms the candidate word, so a send handler that does not check isComposing will fire off half-typed messages.
Markdown that arrives half-parsed. Mid-stream you are holding an opening code fence with no closing one, an unbalanced **, and a table with a header row and nothing under it. A parser given that renders literal backticks, then reflows into a code block a second later, so the block flickers as it grows. Either close open constructs speculatively for rendering only, or buffer the incomplete tail until the next block boundary. Highlight code when the fence closes, not per token: rehighlighting a growing block on every chunk is the second most expensive thing on the page.
Stop, and regenerate, which is not resend. Stop is an AbortController threaded from the button through the fetch, and the partial text has to be kept and marked as stopped rather than thrown away, because most people press stop once they already have what they needed. Aborting the browser request does not by itself stop the model or the billing, so pass the signal through to the provider call too. Regenerate truncates the thread after the target user message and replays from there, which only works if message ids are stable. The moment you keep the old answer next to the new one, a message stops being a string and becomes a list of versions with a selected index. Decide that before you write the store, not after.
Optimistic user messages. Render the message immediately with a client-generated id, clear the composer, then reconcile when the server returns the real id. Reconcile by id and not by array position, or a slow request landing after a fast one will reorder the thread. On failure the message should stay in place with a retry rather than vanishing, and the draft should survive until the send is acknowledged. Losing a long prompt to a dropped connection is the complaint you will hear most.
Virtualising, later than you think. Chat messages have unknown heights that change while they stream and again when images load, so fixed-height virtualisation is simply wrong here. Dynamic measurement works, but it needs scroll anchoring: remeasure an item above the viewport without compensating the offset and everything shifts under the reader. A few hundred messages render fine without any of this. The cheaper first move is rendering a recent window with a load-earlier boundary, which also keeps find-in-page and select-all working, both of which virtualisation quietly breaks.
The live region. A screen reader announces nothing as tokens arrive unless the streaming text sits in a live region. role="log" on the transcript is the right semantic and implies polite announcements of additions. Do not announce per token: mutating a live region forty times a second produces unusable speech. Update at sentence granularity, or keep a visually hidden region that says the assistant is responding and then carries the finished answer. Focus should stay in the composer, and the stop button has to be reachable by keyboard while the reply runs.
Where else to look
| Source | Best for | Trade-off |
|---|---|---|
| Vercel AI SDK | Streaming, tool calls and message state through useChat, plus AI Elements for the UI | The components assume the SDK's message shape, so it is a stack decision, not a file you take |
| assistant-ui | An open-source React chat library that already solves autoscroll, streaming and the composer | It owns the runtime and state layer, so you adopt its abstractions |
| shadcn/ui | The textarea, button and scroll primitives underneath | No chat blocks, so the composition is still yours to write |
| 21st | Community composers and threads, styled and ready to paste | Presentational only: no runtime, no streaming logic, and quality varies by author, so preview before you take it |
The practical split: if chat is the product and you are on the AI SDK, start from AI Elements or assistant-ui and you skip most of the section above. Take a catalogue component when you want a particular look, or when chat is one surface in a larger app.
Taking one
Every component page has a live preview and the source. Installing goes through the shadcn CLI against our registry:
The key comes from your 21st account and installs need a membership, so set API_KEY_21ST once in your shell and the same command works for anything in the catalogue. The 21st MCP does it from Claude, Cursor or Codex if you would rather not leave the editor.
Frequently asked
- What does an AI chat UI need besides a prompt input?
- A scrolling message thread, states for the model's turn (waiting, streaming, stopped, failed), and the logic that keeps the two in sync while text arrives a token at a time. The input and the thread are layout, so they can be taken from a component catalogue. The streaming, autoscroll, stop and regenerate behaviour is application code you either write yourself or inherit from a chat library.
- How do you stream tokens in React without re-rendering the whole chat?
- Keep the in-flight message in its own component with its own state, so appending a token does not update the object holding the entire conversation. Completed messages never change, so memoise them by id and React will skip them on later renders. Batch arriving chunks on an animation frame rather than calling a state setter per chunk, otherwise a stream at 40 tokens a second causes 40 full re-renders a second.
- How should chat autoscroll behave when the user scrolls up?
- It should stop following the stream. Track whether the container is pinned to the bottom by comparing scrollHeight minus scrollTop minus clientHeight against a small threshold, and offer a jump-to-latest control once the reader has scrolled away. The usual bug is that programmatic scrolling fires the same scroll event a person does, so guard the programmatic call with a flag, or treat wheel and touchmove as the signal of real user intent.
- Should I use a chat UI library or copy components for a React chat interface?
- Use a library when chat is the product: Vercel's AI SDK handles streaming, tool calls and message state through useChat, with AI Elements for the UI on top, and assistant-ui is an open-source React chat library that already solves autoscroll, streaming and the composer. Copy components when you want a particular look, or when chat is one surface in a larger app, since catalogue components are presentational and ship no runtime. There are 62 public components under ai chat on 21st, and most of them are prompt inputs.




