Guide

How to build an AI chat interface in React

A chat interface for an AI model looks simple and hides a lot of state: a list that grows while a reply streams in, a scroll position that should follow the output but never fight the reader, a composer that knows when to send and when to stop, and a loading state for the gap before the first token. This guide breaks the interface into those parts and builds it with beUI's AI agent components, which install as source through the shadcn CLI.

The parts of an AI chat UI

Most AI chat screens share the same anatomy. Keep each part separate so you can change one without touching the others, and so the streaming logic never leaks into presentational components.

  • Message list: rows for the user and the assistant, with avatars, names and timestamps.
  • Scroll viewport: follows new output at the bottom and lets go when the reader scrolls up.
  • Streaming response: the assistant text as it arrives, with copy, retry and feedback once it completes.
  • Prompt input: an auto-growing textarea that sends on Enter and turns into a stop button while a reply runs.
  • Loading and thinking states: something calm to show between sending and the first token.
  • Tool calls: visible records of what the agent ran and what came back.

Install the beUI AI agent components

Each component installs from the @beui namespace in the shadcn registry into components/agents. Installing message also brings message-bubble, which it imports. Agent loading states install per variant, so pick thinking-shimmer, reasoning-text or agent-progress. If you want the whole workspace with a sidebar, chat-app installs the shell plus the components its example composes.

ShellTerminal
npx shadcn@latest add @beui/message @beui/message-scroller @beui/prompt-input @beui/streaming-response @beui/thinking-shimmer

Message list and auto-scroll that releases when the reader scrolls up

The classic bug in chat UIs is a scroll container that jumps to the bottom on every token, so anyone who scrolls up to reread an earlier answer gets dragged back down. The fix is to follow only while the reader is at the live edge. MessageScroller watches its content with a ResizeObserver and scrolls to the end when content grows, but only while the reader is within followThreshold pixels of the bottom (56 by default). Scrolling up with the wheel, touch, ArrowUp, PageUp or Home releases it; scrolling back down to the end resumes following.

Wrap the rows in MessageGroup and render each one with Message, which takes from set to user or assistant and lays the row out on the correct side. MessageAvatar, MessageHeader, MessageContent and MessageFooter fill in the row, and MessageBubble with MessageBubbleContent draws the bubble in a variant such as solid, soft or ghost. Set animateIn on newly sent rows for a short pop-up from the live edge; leave it off for history so a long conversation does not animate on load.

MessageScroller also takes onFollowChange, which reports when the reader leaves or returns to the live edge, so you can show a jump to latest button. Set navigation to rail for a compact rail that previews and scrolls to individual messages; give each Message an id when you use it.

TSXchat.tsx
"use client";

import { useState } from "react";
import { ThinkingShimmer } from "@/components/agents/loading-states/thinking-shimmer";
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageGroup,
} from "@/components/agents/message";
import { MessageBubble, MessageBubbleContent } from "@/components/agents/message-bubble";
import { MessageScroller } from "@/components/agents/message-scroller";
import { PromptInput } from "@/components/agents/prompt-input";
import { StreamingResponse } from "@/components/agents/streaming-response";
import { streamReply } from "./stream-reply";

type ChatMessage = {
  id: string;
  from: "user" | "assistant";
  content: string;
  streaming?: boolean;
};

export function Chat() {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [input, setInput] = useState("");
  const [waiting, setWaiting] = useState(false);
  const busy = waiting || messages.some((m) => m.streaming);

  async function send(prompt: string) {
    const id = crypto.randomUUID();
    setMessages((m) => [...m, { id: `u-${id}`, from: "user", content: prompt }]);
    setInput("");
    setWaiting(true);
    await streamReply(prompt, `a-${id}`, setMessages, () => setWaiting(false));
  }

  return (
    <div className="flex h-[640px] flex-col rounded-2xl border border-border">
      <MessageScroller busy={busy} className="min-h-0 flex-1" viewportClassName="px-4 py-5">
        <MessageGroup spacing="default">
          {messages.map((message) => (
            <Message key={message.id} id={message.id} from={message.from} animateIn>
              <MessageAvatar>{message.from === "user" ? "You" : "AI"}</MessageAvatar>
              <MessageContent>
                <MessageBubble variant={message.from === "user" ? "solid" : "soft"}>
                  <MessageBubbleContent>
                    {message.from === "assistant" ? (
                      <StreamingResponse
                        status={message.streaming ? "streaming" : "complete"}
                        copyText={message.content}
                        announce={false}
                      >
                        {message.content}
                      </StreamingResponse>
                    ) : (
                      message.content
                    )}
                  </MessageBubbleContent>
                </MessageBubble>
              </MessageContent>
            </Message>
          ))}
          {waiting ? (
            <Message from="assistant" animateIn>
              <MessageAvatar>AI</MessageAvatar>
              <MessageContent>
                <ThinkingShimmer>Thinking…</ThinkingShimmer>
              </MessageContent>
            </Message>
          ) : null}
        </MessageGroup>
      </MessageScroller>

      <div className="border-border border-t p-3">
        <PromptInput
          value={input}
          onValueChange={setInput}
          onSubmit={send}
          loading={busy}
          minRows={1}
          maxRows={6}
          placeholder="Ask anything…"
        />
      </div>
    </div>
  );
}

Streaming the response token by token

Read the response body as a stream and append each decoded chunk to the assistant message in state. Keep a streaming flag on the message and clear it when the stream ends. Wrap the assistant text in StreamingResponse with status set to streaming or complete: its copy, retry and feedback actions only appear once the response is no longer streaming, so they never jump around under a growing paragraph.

StreamingResponse renders whatever children you give it, so pass plain text or the output of your Markdown renderer. Pass copyText with the raw text for the copy action, onRetry to show a retry button, and sources to add an expandable source summary after streaming. The example below uses fetch with a plain text stream; the same shape works with any SDK that yields chunks.

TSstream-reply.ts
import type { Dispatch, SetStateAction } from "react";

export type ChatMessage = {
  id: string;
  from: "user" | "assistant";
  content: string;
  streaming?: boolean;
};

export async function streamReply(
  prompt: string,
  id: string,
  setMessages: Dispatch<SetStateAction<ChatMessage[]>>,
  onFirstToken: () => void,
) {
  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ prompt }),
  });
  if (!res.body) throw new Error("No response body");

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let started = false;

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    const chunk = decoder.decode(value, { stream: true });
    if (!started) {
      started = true;
      onFirstToken(); // swap the thinking row for the real message
      setMessages((m) => [...m, { id, from: "assistant", content: chunk, streaming: true }]);
      continue;
    }
    setMessages((m) =>
      m.map((msg) => (msg.id === id ? { ...msg, content: msg.content + chunk } : msg)),
    );
  }

  setMessages((m) => m.map((msg) => (msg.id === id ? { ...msg, streaming: false } : msg)));
}

Prompt input, loading states and tool calls

PromptInput grows with its content between minRows and maxRows, sends on Enter, inserts a newline on Shift+Enter, and ignores Enter while an input method editor is composing text. Pass onSubmit to receive the prompt and the selected model, loading while a reply runs, and onStop to turn the send button into a stop button. models with model or defaultModel add a model picker, and actions with onAction add a menu for attachments or context.

Between send and first token, show a thinking row. ThinkingShimmer is a quiet shimmer over a status label. ReasoningText cycles through phrases with an ASCII loader. AgentProgress shows an activity glyph, a verb and a live timer for longer runs. For tool calls, beUI ToolResult shows terminal output or a request payload with running, success, error and cancelled states, and collapses when the run completes.

TSXtest-run.tsx
import { ToolResult, ToolResultOutput } from "@/components/agents/tool-result";

export function TestRun({ running, output }: { running: boolean; output: string }) {
  return (
    <ToolResult
      tool="terminal.run"
      title={running ? "Running tests" : "Tests passed"}
      status={running ? "running" : "success"}
      kind="terminal"
      meta={running ? "Live" : "2.8s"}
      defaultOpen
    >
      <ToolResultOutput>{output}</ToolResultOutput>
    </ToolResult>
  );
}

Accessibility for AI chat interfaces

The transcript needs to be announced without flooding the screen reader. MessageScroller labels its viewport (Conversation by default, change it with label) and renders the content as a log region with aria-live polite and aria-relevant set to additions and text, and it sets aria-busy from the busy prop while a reply is in progress. StreamingResponse has its own polite live region; inside MessageScroller, pass announce={false} so the same text is not announced twice.

Each Message is an article labelled user message or assistant message unless you pass your own aria-label. PromptInput is labelled Prompt by default. ReasoningText and AgentProgress use role status. Under reduced motion, Message drops its pop-up so new rows appear in place, MessageScroller scrolls instantly instead of smoothly, and MessageTyping, ReasoningText and AgentProgress read useReducedMotion to remove their movement.

  • Give the transcript a meaningful label if the page has more than one conversation.
  • Pass busy to MessageScroller while a reply streams.
  • Set announce={false} on StreamingResponse inside a MessageScroller.
  • Keep the composer reachable and focused after sending so keyboard users can continue.

Components in this guide

FAQ

How do I build a ChatGPT-style chat UI in React?
Split it into a message list, a scroll viewport that follows new output, a streaming response surface and a prompt input. beUI provides Message, MessageScroller, StreamingResponse and PromptInput for those parts, installed with npx shadcn@latest add @beui/<name>.
How do I auto-scroll a chat to the bottom without fighting the user?
Only follow while the reader is near the bottom. Measure the distance from the end on scroll, stop following when they scroll up, and resume when they return to the end. beUI MessageScroller does this with a configurable followThreshold.
How do I stream an AI response in React?
Read the response body with a stream reader, decode each chunk and append it to the assistant message in state. Mark the message as streaming until the stream ends, then show completion actions such as copy and retry.
What should a chat UI show while the AI is thinking?
Show a calm status row in the assistant's position, such as a shimmering Thinking label, then replace it with the real message when the first token arrives. For long agent runs, a label with an elapsed timer sets expectations better than a spinner.
Do beUI chat components depend on a specific AI SDK?
No. They take plain props such as message content, status and callbacks, so you can drive them from fetch, the Vercel AI SDK, or any client that streams text.
How do I make a chat transcript accessible to screen readers?
Render the transcript as a log region with aria-live polite, set aria-busy while a reply streams, and avoid nesting a second live region that repeats the same text. beUI MessageScroller handles the log region for you.

Updated