Elements

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