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

> How a production React AI chat UI handles streaming, auto-scroll, IME input and the phone keyboard, part by part, with code for the Next.js App Router.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Building UI with AI agents](https://wingo-ui.com/blog/category/ai-agents)
- Reading time: 10 min
- Canonical: https://wingo-ui.com/blog/react-ai-chat-ui

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

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:

| Part | Job | What breaks first | Wingo UI component |
|---|---|---|---|
| Thread | scrolls the conversation, pins the composer | following the stream while the reader scrolls up | [Chat Thread](https://wingo-ui.com/components/chat-thread) |
| Message | one turn: markdown, files, sources, actions | every message re-rendering on every token | [Chat Message](https://wingo-ui.com/components/chat-message) |
| Composer | text, files, tools, send and stop | Enter during IME composition, the phone keyboard | [Prompt Input](https://wingo-ui.com/components/prompt-input) |
| Reasoning | the model's thinking, folded away | a wall of thought pushing the answer off screen | [Reasoning Indicator](https://wingo-ui.com/components/reasoning-indicator) |
| Model picker | switching models | a cramped popover on a phone | [Model Picker](https://wingo-ui.com/components/model-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](https://wingo-ui.com/blog/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](https://ui.shadcn.com/docs/components/radix/message-scroller) 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](https://wingo-ui.com/components/use-stick-to-bottom) 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.

> Live demo (Chat Thread): 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. Try it at [Chat Thread](https://wingo-ui.com/components/chat-thread) and install it with `npx wingo-ui@latest add chat-thread`.

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()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) 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](https://wingo-ui.com/components/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](https://github.com/vercel/streamdown) calls itself a drop-in replacement built for AI streaming, and its [unterminated block parsing](https://streamdown.ai/docs/termination) 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](https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event) 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](https://developer.chrome.com/blog/viewport-resize-behavior)). 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](https://wingo-ui.com/blog/100vh-mobile-dvh-tailwind) and [Tailwind safe area insets](https://wingo-ui.com/blog/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.

> Live demo (Reasoning Indicator): 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. Try it at [Reasoning Indicator](https://wingo-ui.com/components/reasoning-indicator) and install it with `npx wingo-ui@latest add reasoning-indicator`.

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](https://wingo-ui.com/components/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"`.

> Live demo (AI Chat Page): 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. Try it at [AI Chat Page](https://wingo-ui.com/components/ai-chat-page) and install it with `npx wingo-ui@latest add ai-chat-page`.

## 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 Elements | assistant-ui | Wingo UI AI components |
|---|---|---|---|
| License and price | Apache 2.0, free | MIT, free; Assistant Cloud for hosted threads is optional | Pro: $8 a month, $80 a year or $150 lifetime |
| Built on | shadcn/ui | Primitives you style; a CLI starter in Base UI or Radix with a shadcn/ui theme | Tailwind CSS v4 and Motion, installed as source |
| Chat state | The AI SDK (`useChat`) | Its own runtimes for the AI SDK, LangGraph and custom backends | Yours: you pass `messages` and a `status` |
| Best when | Your app already uses the AI SDK | You want threads, streaming and tool calls managed for you | You own the transport and want scroll rules, phone layouts and announcements built in |

Sources: the [AI Elements docs](https://elements.ai-sdk.dev/components/conversation) and [repository](https://github.com/vercel/ai-elements), the [assistant-ui primitives](https://www.assistant-ui.com/docs/primitives), [runtime guide](https://www.assistant-ui.com/docs/runtimes/pick-a-runtime) and [repository](https://github.com/assistant-ui/assistant-ui), and our [pricing page](https://wingo-ui.com/pricing).

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](https://wingo-ui.com/components/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](https://wingo-ui.com/blog/category/ai-agents).

## Components in this post

- [Chat Thread](https://wingo-ui.com/components/chat-thread) (Pro): The scrolling conversation of an AI chat: pinned to the newest message while it streams, with day pills, follow-ups and a pinned composer. Install: `npx wingo-ui@latest add chat-thread`
- [Chat Message](https://wingo-ui.com/components/chat-message) (Pro): One message of an AI conversation: the question in a bubble, the answer with its thinking, tools, markdown, sources and actions. Install: `npx wingo-ui@latest add chat-message`
- [Prompt Input](https://wingo-ui.com/components/prompt-input) (Pro): The AI chat composer: a growing text box with attachments, tool chips, a model picker, dictation and one round send / stop button. Install: `npx wingo-ui@latest add prompt-input`
- [Reasoning Indicator](https://wingo-ui.com/components/reasoning-indicator) (Pro): 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. Install: `npx wingo-ui@latest add reasoning-indicator`
- [Model Picker](https://wingo-ui.com/components/model-picker) (Pro): 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. Install: `npx wingo-ui@latest add model-picker`
- [AI Chat Page](https://wingo-ui.com/components/ai-chat-page) (Pro): A full AI assistant screen: the conversation history, a streaming thread with reasoning and tools, a composer and the plan's usage. Install: `npx wingo-ui@latest add ai-chat-page`

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

---

Source: https://wingo-ui.com/blog/react-ai-chat-ui
