React AI Chat UI: Streaming, Auto-Scroll and Mobile
A React AI chat UI looks finished after an afternoon: a list of bubbles, a textarea and a fetch that streams text. Real use finds the gaps. The thread drags the reader back down while they reread an earlier answer, a half-written code fence turns the rest of the reply into code, someone typing with a Japanese IME sends half a sentence with Enter, and on an iPhone the composer slides behind the keyboard. This guide covers the five parts of a production chat, what each must handle, and the code that connects them to your model. The examples use the AI components of Wingo UI, the library we build, so we are not neutral; outside facts link to their sources.
What are the parts of a React AI chat UI?
Five parts that share one message type. Each React AI chat UI component owns one job, and each has a failure that shows up first:
The shared type is ChatMessageData from lib/ai-chat: an id, a role, content as markdown and a status of pending, streaming, complete, stopped or error, plus optional reasoning, toolCalls, attachments and sources. The status drives most of the screen: typing dots, the caret, the actions and what a screen reader hears.
The CLI pulls in Chat Message, Reasoning Indicator, Model Picker and Markdown as dependencies. Coding agents can install the same items through our MCP server; the component library MCP guide shows the setup.
How should a chat thread scroll while a reply streams?
Follow new text only while the reader sits at the bottom, let go the moment they scroll up, and come back when they send or tap a jump button. The shadcn Message Scroller docs (opens in a new tab) state the rule: "If they scroll away, leave them there."
What makes it hold up:
- Read intent before position. At the very bottom, a wheel turn upward has not moved
scrollTopyet when the next chunk lands. The thread's useStickToBottom hook lets go on the wheel event itself, so a streaming answer never pulls the reader back. - Follow outside React. Following writes
scrollTopin arequestAnimationFrameloop that re-reads the target every frame. React state changes only when "at the bottom" or "following" flips. - Sending returns to the bottom, even after the reader scrolled up.
- Loading history keeps the eye in place. Before a prepend, the thread records the first visible message and its offset, and restores it afterward.
- The jump pill counts. While scrolled up, a pill above the composer shows how many replies arrived. Tapping it glides down and moves focus to the newest message.
$ npx wingo-ui@latest add chat-threadThe whole loop against your own route:
/api/chat is your route handler. It only has to return the answer as a plain text stream; with the AI SDK, streamText(...).toTextStreamResponse() (opens in a new tab) returns one. The pending message shows typing dots until the first chunk, and Stop keeps the text that already arrived.
How do you stream tokens without re-rendering every message?
Give only the changed message a new object, memoize the message component, and render markdown block by block so each chunk re-parses only the last block.
- Memoized messages. Chat Message is wrapped in
memo, and Chat Thread binds each message's handlers through a ref, so a finished message renders again only when its own object changes. KeepmessagePropsa module constant or wrap it inuseMemo, so its labels and class names keep their identity between chunks. - Markdown repair. The Markdown renderer splits the answer into top-level blocks. While it streams, it repairs the tail before parsing: it closes an open code fence and unmatched
**,~~or backticks, drops a half-typed link target and holds a table back until its separator row arrives. Without that, a reply flips between raw and formatted text on every chunk. If you stay on react-markdown, Streamdown (opens in a new tab) calls itself a drop-in replacement built for AI streaming, and its unterminated block parsing (opens in a new tab) closes open bold, inline code and links. - One announcement per reply. The thread is never a live region, because a screen reader that reads every streamed word is unusable. A separate polite status region says when a reply starts and when it is ready, and the streaming message carries
aria-busy.
What does the composer need to handle?
Enter sends and Shift+Enter adds a line on a hardware keyboard, Enter never sends while an IME is composing, touch keyboards get new lines, and the send button turns into Stop while the answer streams.
IME first, because it fails for whole languages. Japanese, Chinese and Korean input methods use Enter to confirm a conversion. MDN's keydown reference (opens in a new tab) notes that isComposing can be false on the keydown that closes the IME while keyCode is still 229, so the guard checks both. This is the first line of Prompt Input's key handler:
The rest:
- Touch keyboards. They have no Shift+Enter, so plain Enter inserts a new line on a phone and the round button sends without closing the keyboard. The text is 16px on phones, so iOS Safari does not zoom into the field.
- Failed sends. The box clears the moment you send. If
onSubmitreturns a promise that rejects, the text and files come back unless the person has typed again. - Stop. With
status="streaming"the button becomes Stop, andstopShortcut="escape"works while focus is in the composer, so a page dialog keeps its own Escape. - The keyboard covering the box. Since Chrome 108, Chrome on Android resizes only the visual viewport when the keyboard opens, matching Safari on iOS (Chrome's announcement (opens in a new tab)). A composer fixed to the bottom ends up behind it. With
position="fixed", Prompt Input readsvisualViewportand lifts itself by the covered height through a motion value, without re-rendering. Inside Chat Thread you do not need it; the thread pins the composer with safe-area padding. Background: 100vh on mobile and Tailwind safe area insets.
How should reasoning appear in an AI chat?
Folded. While the model thinks, show a shimmering label, a live timer and the last two lines of the thought. When it is done, settle on "Thought for 12 sec" that opens on a tap. Reasoning can run longer than the answer, and nobody should scroll past it to find the reply.
Put reasoning: { content, status, startedAt, endedAt } on the message, or use Reasoning Indicator on its own with content, streaming and startedAt. With autoCollapse (on by default), a row that was open when thinking started closes when it ends, while a row the person opened stays open. On a phone the open body stops at 40% of the screen height and the row keeps a 44px hit area.
$ npx wingo-ui@latest add reasoning-indicatorChat Message folds tool calls the same way, into a one-line summary that expands.
Where does the model picker go on a phone?
Inside the composer, opening as a bottom sheet. On a 390px screen, a 320px popover anchored to a small toolbar button leaves little room for names and descriptions. Below 768px, Model Picker switches to a sheet with 64px rows and two-line descriptions (responsive, on by default). Pass models to Prompt Input and the picker joins its toolbar:
With <PromptInput models={MODELS} defaultModel="standard" />, onSubmit receives the chosen id as message.model. Locked models stay in the list, dimmed, with disabledReason in place of their description, so people see what a higher plan adds instead of wondering where a model went. Above 8 models a search row appears, and modelPickerProps={{ shortcut: "mod+." }} toggles the list from anywhere, text fields included.
How do you wire a full AI chat page to your model?
Use the AI Chat Page block and pass onSend: an async generator that yields text deltas, with a signal that aborts on Stop, on a conversation switch and on unmount. Stop keeps what arrived and marks the message as stopped.
The props after onSend matter. Without them the block shows its sample content: a history of ten conversations, sample models and tools, a usage ring, a plan upsell and a sample user. That is useful in the playground and wrong in production, so pass your own data or empty values. messages in the context already includes the new question.
A thrown error shows on the message with a Retry button. Without onSend, the block runs a scripted mock with reasoning, tool calls and streamed markdown, so you can build the screen before the backend exists. For a React AI chatbot UI in a support panel, use Chat Thread on its own with variant="bubbles" and size="sm".
$ npx wingo-ui@latest add ai-chat-pageWhen is AI Elements or assistant-ui the better choice?
When your app already runs on the AI SDK, or when you want the library to own chat state. Both are free and open source; Wingo UI's AI components are paid and leave state and transport to you. Here is how the three AI chat UI kits compare, as of October 2026:
Sources: the AI Elements docs (opens in a new tab) and repository (opens in a new tab), the assistant-ui primitives (opens in a new tab), runtime guide (opens in a new tab) and repository (opens in a new tab), and our pricing page.
If your app is on the AI SDK and you want a free shadcn chat UI, start with AI Elements. If you want a library to manage the conversation state, assistant-ui does that. Wingo UI fits when the backend is already yours and the screen is the part you do not want to build.
What should you try next?
Open the AI Chat Page docs, try its props in the playground, then install it with npx wingo-ui@latest add ai-chat-page. It and the five components in this guide are part of Wingo UI Pro. More guides on building with coding agents are in Building UI with AI agents.
FAQ
How do I auto-scroll a React chat while the AI response streams?
Follow new content only while the reader is near the bottom, stop following on the first wheel or touch movement upward, and scroll back down when they send a message or press a jump button. Write scrollTop in a requestAnimationFrame loop instead of React state, so streaming does not re-render the list.
Why does my chat input send the message when I press Enter in a Japanese IME?
The Enter that confirms the conversion reaches your keydown handler. Return early when event.nativeEvent.isComposing is true or event.keyCode is 229, because MDN notes that isComposing can be false on the last keydown of a composition.
How do I render streaming markdown in React without flicker?
Repair the unfinished tail before parsing: close an open code fence and unmatched emphasis, drop a half-typed link and hold a table back until its separator row arrives. Render the answer block by block, so finished blocks keep their DOM and only the last one re-parses.
Is there a shadcn chat UI component?
shadcn/ui has a Message Scroller for the transcript, and Vercel's AI Elements is a free component library built on shadcn/ui for apps that use the AI SDK. Wingo UI's Chat Thread, Prompt Input and the other AI components also install as source files you own, and they are part of Wingo UI Pro.
Are the Wingo UI AI chat components free?
No. Chat Thread, Chat Message, Prompt Input, Reasoning Indicator, Model Picker and the AI Chat Page block are part of Wingo UI Pro. The 75 free items include basics they build on, such as Button, Textarea and Tooltip.
- AI chat
- Streaming
- React
- Mobile UI
- Accessibility