# Build a Streaming AI Chat UI in React with shadcn/ui (2026 Guide)

> Source: <https://dev.to/cuelark/build-a-streaming-ai-chat-ui-in-react-with-shadcnui-2026-guide-2p42>
> Published: 2026-10-31 12:09:37+00:00

Getting tokens from a model to the browser is the easy part. Every modern SDK does it in a few lines. The hard part is everything the user actually experiences while those tokens arrive: the view that yanks them back to the bottom while they are trying to read, the Send button that does nothing while a reply runs, the paragraph that reflows every 40 milliseconds, the screen reader that either announces nothing or announces every single token.

This guide builds a streaming chat interface in React that gets those details right. We will use shadcn/ui as the foundation, the Vercel AI SDK for the transport, and four open-source components from Cuelark UI for the parts shadcn does not ship: `Conversation`, `Message`, `StreamingText` and `PromptInput`. Every component is installed as source, so if you disagree with a decision we made, you can change the file.

By the end you will have:

Before writing code, it helps to name the problems. A static list of messages is a solved problem. Streaming adds four new ones.

**Scroll ownership.** While a reply streams, the view should stick to the bottom. The instant the reader scrolls up, the interface must stop chasing new tokens, because the reader has told you they want to read something else. When they want to come back, they need a fast way to do it.

**Interruptibility.** A reply in progress is a running process. If the model has misunderstood the question, the user should be able to stop it immediately, from the keyboard as well as the mouse. A text box with no Stop button during streaming feels broken.

**Visual stability.** Each chunk that arrives can cause the paragraph to reflow. Words that were already read should not move, flash or re-render with a different weight.

**Announcements.** Assistive technology should hear that a reply is arriving without being flooded token by token.

The four components below map one to one onto those problems.

You need a React project configured for shadcn/ui with Tailwind CSS v4. Next.js, Vite, TanStack Start, React Router and Astro all work. If you are starting fresh:

```
npx shadcn@latest init
```

Then add the Cuelark registry to `components.json` once, so you can install components by name:

```
{
  "registries": {
    "@cuelark": "https://ui.cuelark.com/r/{name}.json"
  }
}
```

Now install the four chat components:

```
npx shadcn@latest add @cuelark/conversation @cuelark/message @cuelark/streaming-text @cuelark/prompt-input
```

If you would rather not touch `components.json`, each component also installs by URL, for example `npx shadcn@latest add https://ui.cuelark.com/r/conversation.json`. The CLI brings the theme tokens and the motion layer along with whatever you add. The full set of options is on the [installation page](https://ui.cuelark.com/docs/installation).

Finally, install the AI SDK packages:

```
npm install ai @ai-sdk/react @ai-sdk/openai
```

The examples use OpenAI, but any AI SDK provider works the same way.

The server side is short. This is a Next.js App Router route; the shape is similar for other frameworks.

``` js
// app/api/chat/route.ts
import { openai } from "@ai-sdk/openai";
import { streamText, convertToModelMessages, type UIMessage } from "ai";

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: openai("gpt-4o-mini"),
    messages: convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse();
}
```

The snippets here are written against AI SDK 5's `useChat` and UI message stream. If you are on a different major version, the hook names may differ slightly; the UI layer does not change.

Here is the skeleton of the chat page.

``` js
"use client";

import { useChat } from "@ai-sdk/react";
import { Conversation, ConversationContent } from "@/components/ui/conversation";
import { Message, MessageContent } from "@/components/ui/message";
import { StreamingText } from "@/components/ui/streaming-text";
import { PromptInput } from "@/components/ui/prompt-input";

export default function Chat() {
  const { messages, sendMessage, status, stop } = useChat();
  const isStreaming = status === "submitted" || status === "streaming";

  return (
    <div className="mx-auto flex h-dvh max-w-3xl flex-col gap-4 p-4">
      <Conversation className="flex-1">
        <ConversationContent>
          {/* messages go here */}
        </ConversationContent>
      </Conversation>

      <PromptInput
        status={isStreaming ? "streaming" : "idle"}
        onSubmit={({ text }) => sendMessage({ text })}
        onStop={stop}
      />
    </div>
  );
}
```

`Conversation` is the piece that handles scroll ownership. It follows the newest message while a reply streams and stops following as soon as the reader scrolls up. When they are away from the bottom, a "Latest" pill appears to jump back. You do not wire any of this yourself: no `IntersectionObserver`, no `scrollIntoView` in an effect, no "is the user near the bottom" threshold to tune.

It also renders with `role="log"`, which tells screen readers this is a region where new content arrives in order, and announcements are polite, so they do not interrupt whatever the user is doing. The "Latest" pill is skipped by Tab while it is hidden.

A common mistake is to put the scroll container's height on a parent and let the container grow. Give `Conversation` the flexible space (here `flex-1` inside a full-height column) so it is the element that scrolls.

Each turn is a `Message`. User turns render as a compact bubble; assistant turns read as a full-width document with an avatar and quiet actions (copy, regenerate, rate) that appear on hover and stay visible on touch devices.

``` js
<ConversationContent>
  {messages.map((m, i) => {
    const text = m.parts
      .filter((p) => p.type === "text")
      .map((p) => p.text)
      .join("");
    const isLast = i === messages.length - 1;

    return (
      <Message key={m.id} from={m.role === "user" ? "user" : "assistant"}>
        <MessageContent>
          {m.role === "assistant" ? (
            <StreamingText text={text} streaming={isStreaming && isLast} />
          ) : (
            text
          )}
        </MessageContent>
      </Message>
    );
  })}
</ConversationContent>
```

`StreamingText` takes two props: the `text` so far and whether it is still `streaming`. Each new chunk fades up out of a slight blur rather than popping in, a small caret marks the writing edge while streaming is true, and text already on screen never moves. That last property is the one that matters for long answers: people read streamed text as it arrives, and a paragraph that keeps shifting is tiring to follow.

Only the last assistant message should be marked as streaming. Earlier turns are history and should render as finished text with no caret.

`PromptInput` is the composer, and it does more than a textarea:

`attachments={false}` to turn that off);`status` is `"streaming"`, Send becomes Stop, and Esc stops the run.
That swap is the interruptibility problem solved. Pass the SDK's `stop` function to `onStop` and you are done. The component also accepts a `toolbar` prop for extra controls, which is where a model picker belongs if your product lets people trade speed for quality. The `onSubmit` callback receives `{ text, files }`, so file uploads use the same handler.

Three small details separate a demo from a product.

**The gap before the first token.** Between pressing Send and the first chunk, the AI SDK reports `status === "submitted"`. Treating that as streaming (as the code above does) means the Stop button appears immediately, which is exactly when users most often realise they asked the wrong question.

**Errors.** When `status` becomes `"error"`, show it inside the conversation, next to the turn that failed, not as a toast that disappears. The user needs to see which message did not get an answer.

**Empty state.** An empty `Conversation` is a blank rectangle. A few suggested prompts tell people what the assistant is good at. Cuelark UI has a `Suggestions` component for this; three suggestions work better than eight.

Because everything installs as source styled with Tailwind CSS v4 tokens, the chat picks up your existing shadcn theme. If you want a starting point, the registry includes ready-made themes you can install with one command; browse them on the [theme gallery](https://ui.cuelark.com/themes).

Motion is controlled globally by one CSS variable, `--cl-motion`. Set it to `0` for a dense internal tool where you want nothing to move, or raise it on a marketing demo. A visitor's reduced-motion setting always wins over whatever you choose.

```
:root {
  --cl-motion: 0.5;
}
```

Before you ship, test these with a keyboard and a screen reader:

The components cover these by default, but your layout can break them: a parent with `overflow: hidden`, a custom focus style that removes outlines, or a wrapper that steals Enter.

A plain chat is the start. Once the model can think, call tools and ask permission, the interface needs more places to put things: reasoning that folds away, tool calls shown as facts, approvals that are hard to click through by accident. We cover those in the companion article on tool calls, reasoning and approvals, and you can see all of it working together in the [live research agent example](https://ui.cuelark.com/agents/research-agent), where every part of the interface can be inspected and copied.

**Is Cuelark UI free?** Yes. It is open source under the MIT licence, and the code is on GitHub at github.com/cuelark/ui.

**Do I have to use the Vercel AI SDK?** No. The components take plain props (`text`, `streaming`, `status`, `onSubmit`, `onStop`), so any streaming source works.

**Can I change how the components look?** Yes. They are copied into your project as source, so you edit them like any other file in your codebase.
