Elements

Chat panel

The whole family working together: a message, a pause, a streamed reply.

Why did my draft disappear?
Message
fig. 01 · plays once, replay from the corner

Installation

npx shadcn@latest add "@assistant-ui/elements-chat-panel"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

A chat panel is the smallest complete slice of a thread: a scrollable message list, a bubble per role, a beat while the reply is in flight, and a composer that sends. With a runtime the pieces wire to live thread state; standalone you arrange the same pieces around whatever messages and handlers you pass in.

Getting started

assistant-ui's Thread element already ships a complete version of this composition, wired end to end from viewport to composer. Build it from these pieces yourself only when you want a smaller, custom shell instead of the full Thread.

Render the message list

ThreadPrimitive.Viewport takes over scrolling from ChatPanelMessages; pass it as the asChild target so the panel keeps ChatPanelMessages's look while the viewport owns the behavior:

components/assistant-ui/elements/chat-panel.tsx
"use client";

import { ThreadPrimitive, MessagePrimitive } from "@assistant-ui/react";
import { ChatPanel, ChatPanelMessages, ChatPanelUserMessage, ChatPanelAssistantMessage } from "./chat-panel";

export function LiveChatPanel() {
  return (
    <ChatPanel>
      <ThreadPrimitive.Viewport asChild>
        <ChatPanelMessages>
          <ThreadPrimitive.Messages>
            {({ message }) =>
              message.role === "user" ? (
                <ChatPanelUserMessage>
                  <MessagePrimitive.Parts />
                </ChatPanelUserMessage>
              ) : (
                <ChatPanelAssistantMessage>
                  <MessagePrimitive.Parts />
                </ChatPanelAssistantMessage>
              )
            }
          </ThreadPrimitive.Messages>
        </ChatPanelMessages>
      </ThreadPrimitive.Viewport>
    </ChatPanel>
  );
}

ThreadPrimitive.Messages's render function is called once per message, already scoped to it, so MessagePrimitive.Parts and message.role inside resolve to that message.

Add a composer and a running state

import { AuiIf, ComposerPrimitive } from "@assistant-ui/react";
import { ChatPanelComposer, ChatPanelTyping } from "./chat-panel";

<AuiIf condition={(s) => s.thread.isRunning}>
  <ChatPanelTyping />
</AuiIf>

<ComposerPrimitive.Root className="mx-3 mb-3 flex h-10 shrink-0 items-center rounded-full">
  <ComposerPrimitive.Input placeholder="Message..." />
  <ComposerPrimitive.Send />
</ComposerPrimitive.Root>

s.thread.isRunning is true for as long as a stream is connected to the backend, which is when ChatPanelTyping's bounce reads as thinking. ComposerPrimitive.Send disables itself the same way ChatPanelComposer's button does, whenever the composer has nothing to send.

Anatomy

<div data-slot="chat-panel">
  <div data-slot="chat-panel-messages">
    <div data-slot="chat-panel-user-message" />
    <p data-slot="chat-panel-assistant-message" />
    <div data-slot="chat-panel-typing" />
  </div>
  <div data-slot="chat-panel-composer">
    <span>{/* placeholder text */}</span>
    <button aria-label="Send" />
  </div>
</div>

ChatPanelComposer is a shell, not a text field: placeholder is text it displays, not a bound value, and the send button's only behavior is calling onSend when present. It renders disabled whenever onSend is undefined, the same rule EmptyStateComposer follows. ChatPanelTyping and ChatPanelUserMessage both fade and slide in on mount; change their key if you want that entrance to replay for a new message.

Examples

Reuse pieces individually

Every part, ChatPanel, ChatPanelMessages, ChatPanelUserMessage, ChatPanelAssistantMessage, ChatPanelTyping, and ChatPanelComposer, accepts className and forwards the rest of its element's props, so pieces recombine outside the default card shape, for example a full-height page shell instead of the fixed h-[270px] card:

<ChatPanel className="h-full max-w-none rounded-none border-none">
  {/* ... */}
</ChatPanel>

Typing state

Gate ChatPanelTyping on the thread's running flag so it only shows while a reply is in flight:

<AuiIf condition={(s) => s.thread.isRunning}>
  <ChatPanelTyping />
</AuiIf>

API reference

Primitive parts

PartRendersNotes
ThreadPrimitive.ViewportdivScrollable message area; auto-scrolls to the bottom on new messages by default. Accepts asChild.
ThreadPrimitive.MessageschildrenCalls its children render function once per message, already scoped to that message.
MessagePrimitive.PartschildrenRenders the active message's content.
ComposerPrimitive.Root / .Input / .Sendform / textarea / buttonThe real composer; .Send disables itself when the composer can't send.
AuiIfchildrenRenders children while condition selects true from state.

Thread state

SelectorTypeDescription
s.thread.isRunningbooleantrue while a stream is connected to the backend.
s.message.role"user" | "assistant" | "system"Read inside ThreadPrimitive.Messages's render function to pick a bubble.