06 / 37 · Messages
Message pair
A user bubble and a streaming assistant reply, with actions that appear on hover.
How do I persist composer drafts across threads?
Installation
npx shadcn@latest add "@assistant-ui/elements-message-pair"
Usage
import { MessagePair } from "@/components/elements/message-pair";
<MessagePair
userMessage="Explain the composer."
words={["Here", "is", "a", "short", "reply."]}
visibleWords={5}
streaming={false}
/>Props
userMessage*stringText shown in the user bubble on the right.
words*readonly string[]Assistant reply broken into words for streaming.
visibleWords*numberHow many assistant words are currently visible.
streaming*booleanWhen true the newest assistant words tint blue as they arrive.
variant"bubble" | "flat"bubble wraps the user message in a surface; flat renders it as plain right-aligned text.
classNamestringExtra classes merged onto the root.
Source
message-pair.tsx"use client";
import { CopyIcon, RefreshCwIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { ghostButton, paper } from "./surfaces";
export interface MessagePairProps {
userMessage: string;
words: readonly string[];
visibleWords: number;
streaming: boolean;
variant?: "bubble" | "flat";
className?: string;
}
export function MessagePair({
userMessage,
words,
visibleWords,
streaming,
variant = "bubble",
className,
}: MessagePairProps) {
return (
<div className={cn("flex w-full max-w-sm flex-col gap-5", className)}>
<p
className={cn(
"max-w-[85%] self-end text-sm",
variant === "bubble"
? cn(paper, "rounded-2xl px-3.5 py-2")
: "text-foreground/90 text-end",
)}
>
{userMessage}
</p>
<div className="group/message flex flex-col items-start">
<p className="min-h-[4.25rem] text-sm leading-relaxed">
{words.slice(0, visibleWords).map((word, index) => {
const fresh = streaming && visibleWords - 1 - index < 2;
return (
<span
key={`${word}-${index}`}
className="fade-in animate-in fill-mode-both duration-500 motion-reduce:animate-none"
>
<span
className={cn(
"transition-colors duration-700 motion-reduce:transition-none",
fresh && "text-blue-500 dark:text-blue-400",
)}
>
{word}
</span>{" "}
</span>
);
})}
{streaming && visibleWords > 0 && (
<span
aria-hidden
className="-mb-0.5 ml-0.5 inline-block h-4 w-0.5 animate-pulse rounded-full bg-blue-500 motion-reduce:animate-none dark:bg-blue-400"
/>
)}
</p>
<div className="flex items-center gap-1 pt-1 opacity-0 transition-opacity group-focus-within/message:opacity-100 group-hover/message:opacity-100 motion-reduce:transition-none">
<button
type="button"
aria-label="Copy response"
className={cn(ghostButton, "size-7")}
>
<CopyIcon className="size-3.5" />
</button>
<button
type="button"
aria-label="Regenerate response"
className={cn(ghostButton, "size-7")}
>
<RefreshCwIcon className="size-3.5" />
</button>
</div>
</div>
</div>
);
}