跳到正文
DEV Community· Cuelark·· 2 小時前AI 評分62

React 如何結合 shadcn/ui 建構串流 AI 聊天介面(2026 指南)

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

AI 導讀

Cuelark 的指南示範如何以 React、shadcn/ui、Vercel AI SDK 和 Cuelark UI 元件建構串流 AI 聊天介面。介面會在用戶向上捲動時停止自動追隨訊息,並在串流期間把 Send 切換為 Stop,亦可按 Esc 中止回覆。StreamingText 令已顯示文字不會移位,並以禮貌方式通知螢幕閲讀器有新內容,避免逐個 token 公告。

正文

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:

  • a scroll container that follows a streaming reply and stops following the moment the reader scrolls up;
  • a composer that turns Send into Stop, and lets Esc cancel a run;
  • streamed text that never moves what is already on screen;
  • sensible accessibility defaults for all of the above.

What a streaming chat needs beyond a message list

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.

1. Set up the project

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.

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.

2. The route handler

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

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

3. The scroll container: Conversation

Here is the skeleton of the chat page.

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

4. Rendering turns: Message and StreamingText

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.

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

5. The composer: PromptInput

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

  • it grows with the text;
  • Enter sends and Shift+Enter adds a line, and IME composition is respected, so users typing in Japanese, Chinese or Korean do not send half-composed words;
  • files can be attached by button, paste or drop (pass attachments={false} to turn that off);
  • while 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.

6. Handle the in-between states

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.

7. Theming and motion

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.

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;
}

8. Accessibility checklist

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

  1. You can reach the composer, type, send with Enter and stop with Esc without touching the mouse.
  2. New assistant text is announced politely, not token by token.
  3. Scrolling up during a reply stops auto-scroll, and the "Latest" pill is reachable by keyboard once visible.
  4. Focus is visible on every control, including message actions.
  5. With reduced motion enabled at the OS level, nothing animates.

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.

What to build next

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, where every part of the interface can be inspected and copied.

FAQ

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.

來源:DEV Community · dev.to