88 / 122 · Thread
Chat panel
The whole family working together: a message, a pause, a streamed reply.
Message
Installation
npx shadcn@latest add "@assistant-ui/elements-chat-panel"
Usage
import {
ChatPanel,
ChatPanelComposer,
ChatPanelMessages,
ChatPanelTyping,
ChatPanelUserMessage,
} from "@/components/elements/chat-panel";
<ChatPanel>
<ChatPanelMessages>
<ChatPanelUserMessage>Why did my draft disappear?</ChatPanelUserMessage>
<ChatPanelTyping />
</ChatPanelMessages>
<ChatPanelComposer placeholder="Message" onSend={send} />
</ChatPanel>Props
ChatPanel
childrenReact.ReactNodeThe panel is a shell: a scrolling message area and a composer bar. What goes in either one is yours, so a real thread renders as many turns as it has.
...propsReact.ComponentProps<"div">Spread onto the root, as on every part below.
ChatPanelMessages
childrenReact.ReactNodeTurns, oldest first. The area is bottom-aligned and scrolls, so the newest turn stays in view.
ChatPanelAssistantMessage
childrenReact.ReactNodeA settled assistant turn. For a streaming one, drop the streaming-text element in here instead; it renders its own paragraph.
ChatPanelComposer
placeholder*stringResting text in the input bar.
onSend() => voidCalled from the send control. Omit it and the control renders disabled.
Source
chat-panel.tsx"use client";
import type { ComponentProps } from "react";
import { ArrowUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { field, inkButton, paper } from "./surfaces";
export function ChatPanel({ className, ...props }: ComponentProps<"div">) {
return (
<div
data-slot="chat-panel"
className={cn(
paper,
"flex h-[270px] w-full max-w-md flex-col overflow-hidden rounded-[24px]",
className,
)}
{...props}
/>
);
}
export function ChatPanelMessages({
className,
...props
}: ComponentProps<"div">) {
return (
<div
data-slot="chat-panel-messages"
className={cn(
"flex flex-1 flex-col justify-end gap-2.5 overflow-y-auto p-4",
className,
)}
{...props}
/>
);
}
export function ChatPanelUserMessage({
className,
...props
}: ComponentProps<"div">) {
return (
<div
data-slot="chat-panel-user-message"
className={cn(
field,
"fade-in slide-in-from-bottom-1 animate-in self-end rounded-2xl px-3 py-1.5 text-xs duration-300",
className,
)}
{...props}
/>
);
}
export function ChatPanelAssistantMessage({
className,
...props
}: ComponentProps<"p">) {
return (
<p
data-slot="chat-panel-assistant-message"
className={cn(
"text-foreground/70 max-w-[85%] self-start text-xs leading-relaxed",
className,
)}
{...props}
/>
);
}
export function ChatPanelTyping({
className,
...props
}: Omit<ComponentProps<"div">, "children">) {
return (
<div
data-slot="chat-panel-typing"
className={cn(
"fade-in animate-in flex gap-1 self-start px-1 duration-300",
className,
)}
{...props}
>
{["-0.32s", "-0.16s", "0s"].map((delay) => (
<span
key={delay}
aria-hidden
className="bg-foreground/40 size-1 animate-bounce rounded-full motion-reduce:animate-none"
style={{ animationDelay: delay, animationDuration: "1.1s" }}
/>
))}
</div>
);
}
export function ChatPanelComposer({
placeholder,
onSend,
className,
...props
}: Omit<ComponentProps<"div">, "children" | "placeholder"> & {
placeholder: string;
onSend?: () => void;
}) {
return (
<div
data-slot="chat-panel-composer"
className={cn(
field,
"mx-3 mb-3 flex h-10 shrink-0 items-center justify-between rounded-full py-1.5 ps-4 pe-1.5",
className,
)}
{...props}
>
<span className="text-foreground/35 text-[13px]">{placeholder}</span>
<button
type="button"
aria-label="Send"
onClick={onSend}
disabled={!onSend}
className={cn(
inkButton,
"flex size-7 items-center justify-center rounded-full disabled:pointer-events-none disabled:opacity-30",
)}
>
<ArrowUpIcon className="size-3.5" />
</button>
</div>
);
}