WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Building UI with AI agents
  4. React AI Chat UI: Streaming, Auto-Scroll and Mobile
  1. Blog
  2. Building UI with AI agents
  3. React AI Chat UI: Streaming, Auto-Scroll and Mobile
Building UI with AI agents
Building UI with AI agents

React AI Chat UI: Streaming, Auto-Scroll and Mobile

SR
Serban Rusu · Founder of Wingo UI
Oct 9, 2026 · 10 min read

On this page

0%
  1. What are the parts of a React AI chat UI?
  2. How should a chat thread scroll while a reply streams?
  3. How do you stream tokens without re-rendering every message?
  4. What does the composer need to handle?
  5. How should reasoning appear in an AI chat?
  6. Where does the model picker go on a phone?
  7. How do you wire a full AI chat page to your model?
  8. When is AI Elements or assistant-ui the better choice?
  9. What should you try next?
  10. FAQ
    1. How do I auto-scroll a React chat while the AI response streams?
    2. Why does my chat input send the message when I press Enter in a Japanese IME?
    3. How do I render streaming markdown in React without flicker?
    4. Is there a shadcn chat UI component?
    5. Are the Wingo UI AI chat components free?

TL;DR

A production React AI chat UI has five parts that share one message type: a thread, the messages, a composer, a reasoning row and a model picker. The work is in their behavior: follow the stream only while the reader is at the bottom, re-render only the message that changed, repair half-written markdown, ignore Enter during IME composition and keep the composer above the phone keyboard. Stream each delta into a new object for that one message, and abort with an AbortSignal so Stop keeps the partial answer.

Published Oct 9, 2026

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:

PartJobWhat breaks firstWingo UI component
Threadscrolls the conversation, pins the composerfollowing the stream while the reader scrolls upChat Thread
Messageone turn: markdown, files, sources, actionsevery message re-rendering on every tokenChat Message
Composertext, files, tools, send and stopEnter during IME composition, the phone keyboardPrompt Input
Reasoningthe model's thinking, folded awaya wall of thought pushing the answer off screenReasoning Indicator
Model pickerswitching modelsa cramped popover on a phoneModel Picker

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.

bash
npx wingo-ui@latest add chat-thread prompt-input

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 scrollTop yet 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 scrollTop in a requestAnimationFrame loop 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.
Pick a follow-up or type a question, then scroll up while the answer streams: the thread lets go at once and a pill counts the new reply. Tap the pill to glide back down
$ npx wingo-ui@latest add chat-thread
ProChat Thread docs

The whole loop against your own route:

tsx
"use client";
import { useRef, useState } from "react";
import { ChatThread, type ChatMessageData } from "@/components/ui/chat-thread";
import { PromptInput, type PromptInputMessage } from "@/components/ui/prompt-input";
import { createChatId } from "@/lib/ai-chat";
export function Assistant() {
const [messages, setMessages] = useState<ChatMessageData[]>([]);
const [streaming, setStreaming] = useState(false);
const abort = useRef<AbortController | null>(null);
const patch = (id: string, change: Partial<ChatMessageData>) =>
setMessages((list) => list.map((m) => (m.id === id ? { ...m, ...change } : m)));
async function send({ text }: PromptInputMessage) {
const question: ChatMessageData = { id: createChatId("u"), role: "user", content: text, createdAt: Date.now() };
const history = [...messages, question].map(({ role, content }) => ({ role, content }));
const id = createChatId("a");
setMessages((list) => [...list, question, { id, role: "assistant", content: "", status: "pending", createdAt: Date.now() }]);
const controller = new AbortController();
abort.current = controller;
setStreaming(true);
let content = "";
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history }),
signal: controller.signal,
});
if (!response.ok || !response.body) throw new Error("The assistant is unavailable.");
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
content += value;
patch(id, { content, status: "streaming" });
}
patch(id, { status: "complete" });
} catch {
patch(id, { status: controller.signal.aborted ? "stopped" : "error" });
} finally {
setStreaming(false);
}
}
return (
<div className="h-dvh">
<ChatThread
messages={messages}
composer={
<PromptInput
status={streaming ? "streaming" : "idle"}
onSubmit={send}
onStop={() => abort.current?.abort()}
stopShortcut="escape"
/>
}
/>
</div>
);
}

/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. Keep messageProps a module constant or wrap it in useMemo, 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:

tsx
if (event.nativeEvent.isComposing || event.key === "Process" || event.keyCode === 229) return;

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 onSubmit returns a promise that rejects, the text and files come back unless the person has typed again.
  • Stop. With status="streaming" the button becomes Stop, and stopShortcut="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 reads visualViewport and 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.

Watch the label shimmer while the timer counts and the last lines of the thought slide up. When it settles, tap the row to read the full reasoning
$ npx wingo-ui@latest add reasoning-indicator
ProReasoning Indicator docs

Chat 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:

tsx
// app/assistant/models.ts
import type { AiModel } from "@/components/ui/model-picker";
export const MODELS: AiModel[] = [
{ id: "standard", name: "Northwind Standard", description: "Balanced, for everyday work", recommended: true },
{ id: "pro", name: "Northwind Pro", description: "Deep reasoning for reports", disabled: true, disabledReason: "Available on the Business plan" },
];

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.

tsx
// app/assistant/page.tsx
"use client";
import { AiChatPage, type AiChatSendHandler } from "@/components/blocks/ai-chat-page";
import { MODELS } from "./models";
const onSend: AiChatSendHandler = async function* (message, { model, messages, signal }) {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, messages: messages.map(({ role, content }) => ({ role, content })) }),
signal,
});
if (!response.ok || !response.body) throw new Error("The assistant is unavailable.");
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
const { value, done } = await reader.read();
if (done) return;
yield value;
}
};
export default function AssistantPage() {
return (
<AiChatPage
onSend={onSend}
defaultConversationId={null}
defaultConversations={[]}
models={MODELS}
defaultModel="standard"
tools={[]}
suggestions={[]}
usage={[]}
plan={null}
user={{ id: "emma", name: "Emma Carter" }}
assistantName="Northwind Assistant"
/>
);
}

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".

In a frame this narrow you get the phone layout: the history sits behind the menu button and the model picker moves into the composer. Send a question, then press Stop while it streams
$ npx wingo-ui@latest add ai-chat-page
ProAI Chat Page docs

When 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:

AI Elementsassistant-uiWingo UI AI components
License and priceApache 2.0, freeMIT, free; Assistant Cloud for hosted threads is optionalPro: $8 a month, $80 a year or $150 lifetime
Built onshadcn/uiPrimitives you style; a CLI starter in Base UI or Radix with a shadcn/ui themeTailwind CSS v4 and Motion, installed as source
Chat stateThe AI SDK (useChat)Its own runtimes for the AI SDK, LangGraph and custom backendsYours: you pass messages and a status
Best whenYour app already uses the AI SDKYou want threads, streaming and tool calls managed for youYou own the transport and want scroll rules, phone layouts and announcements built in

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.

Components in this post

  • Chat Thread

    The scrolling conversation of an AI chat: pinned to the newest message while it streams, with day pills, follow-ups and a pinned composer.

    Pro
  • Chat Message

    One message of an AI conversation: the question in a bubble, the answer with its thinking, tools, markdown, sources and actions.

    Pro
  • Prompt Input

    The AI chat composer: a growing text box with attachments, tool chips, a model picker, dictation and one round send / stop button.

    Pro
  • Reasoning Indicator

    The model's thinking, kept out of the way: a shimmering live timer while it thinks, then a quiet "Thought for 12 s" to expand.

    Pro
  • Model Picker

    A model switcher for AI chats: a quiet trigger that opens a short grouped list of models, each with its name and one line on what it is for.

    Pro
  • AI Chat Page

    A full AI assistant screen: the conversation history, a streaming thread with reasoning and tools, a composer and the plan's usage.

    Pro

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

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu is the founder of Wingo UI. He builds the component library, its CLI and its MCP server, and writes about React interfaces that work well on phones and with AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Building UI with AI agents

AGENTS.md vs CLAUDE.md vs Cursor Rules for React

AGENTS.md vs CLAUDE.md vs Cursor rules for React and Tailwind: which agent reads which file in October 2026, and a rules file that keeps agents on your tokens.

SRSerban Rusu·Oct 9, 2026·14 min read
Building UI with AI agents

Claude Code shadcn Setup: MCP Server, Skill and CLAUDE.md

Claude Code shadcn setup, tested on shadcn 4.21.4: the MCP server, the skill, a second registry in its own folder, a CLAUDE.md block and prompts that work.

SRSerban Rusu·Oct 9, 2026·12 min read
Building UI with AI agents

Component Library MCP: Make Agents Use Real Components

How a component library MCP server, rules files, llms.txt and a registry get Claude Code, Cursor and Codex to install real components instead of guessing.

SRSerban Rusu·Oct 9, 2026·25 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components. Copy the source, make it yours, and let your coding agent build with it.

ComponentsTemplatesPricingBlogTheme

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities
Wingo UI