Elements

Message pair

A user bubble and a streaming assistant reply, with actions that appear on hover.

How do I persist composer drafts across threads?

fig. 01 · plays once, replay from the corner

Installation

npx shadcn@latest add "@assistant-ui/elements-message-pair"
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 message pair is one turn of a conversation: the message you sent, and the reply landing beneath it with copy and regenerate tucked away until you hover. bubble wraps the sent message in a filled pill; flat sets it as plain right-aligned text with no container. With a runtime the pair is two composed messages driven by the thread; standalone you hand it the sent text and the words to reveal.

Getting started

A thread renders as a list of messages, each one either from the user or the assistant. Compose the pair from MessagePrimitive.Root per message and an action row that only shows on hover.

Compose the pair

ThreadPrimitive.Messages iterates the thread; branch on s.message.role to choose which half of the pair to render.

components/assistant-ui/elements/message-pair.tsx
"use client";

import { MessagePrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react";
import { cn } from "@/lib/utils";
import { paper } from "@/components/assistant-ui/elements/surfaces";

export function Turn() {
  return <ThreadPrimitive.Messages>{() => <TurnMessage />}</ThreadPrimitive.Messages>;
}

function TurnMessage() {
  const role = useAuiState((s) => s.message.role);
  return (
    <MessagePrimitive.Root className="flex w-full flex-col gap-5">
      {role === "user" ? (
        <div className={cn(paper, "max-w-[85%] self-end rounded-2xl px-3.5 py-2 text-sm")}>
          <MessagePrimitive.Parts />
        </div>
      ) : (
        <AssistantReply />
      )}
    </MessagePrimitive.Root>
  );
}

Reveal actions on hover

Wrap the action row in ActionBarPrimitive.Root with autohide="always": it renders nothing until s.message.isHovering turns true, which MessagePrimitive.Root already tracks from pointer enter and leave.

import { ActionBarPrimitive } from "@assistant-ui/react";
import { CopyIcon, RefreshCwIcon } from "lucide-react";
import { ghostButton } from "@/components/assistant-ui/elements/surfaces";

function AssistantReply() {
  return (
    <div className="group/message flex flex-col items-start">
      <div className="min-h-[4.25rem] text-sm leading-relaxed">
        <MessagePrimitive.Parts />
      </div>
      <ActionBarPrimitive.Root autohide="always" className="flex items-center gap-1 pt-1">
        <ActionBarPrimitive.Copy aria-label="Copy response" className={cn(ghostButton, "size-7")}>
          <CopyIcon className="size-3.5" />
        </ActionBarPrimitive.Copy>
        <ActionBarPrimitive.Reload aria-label="Regenerate response" className={cn(ghostButton, "size-7")}>
          <RefreshCwIcon className="size-3.5" />
        </ActionBarPrimitive.Reload>
      </ActionBarPrimitive.Root>
    </div>
  );
}

The Thread element already ships a user and assistant message composed this closely, though its action row uses autohide="not-last" (hover-gated on older replies, always visible on the newest one) rather than the strict hover-only behavior modeled here; install @assistant-ui/thread for the full, richer version.

Anatomy

<div data-slot="message-pair">
  <p>{/* the sent message */}</p>
  <div>
    <p>{/* the reply, word by word */}</p>
    <div>{/* copy, regenerate, hidden until hover or focus */}</div>
  </div>
</div>

Hovering or focusing anywhere in the reply's group reveals the action row through opacity-0 to opacity-100; each word fades in on its own, and the newest two words tint blue while streaming is true before settling to ink over 700ms. A trailing cursor blinks after the last word while streaming and disappears once streaming is false or nothing has been revealed yet. Standalone this reveal is driven by visibleWords catching up to words.length, a controlled typewriter useful for demos and replays. A runtime instead just renders the accumulated text as it streams in: there is no separate reveal count to manage, and the default Text part renderer shows a trailing "●" (through MessagePartPrimitive.InProgress) rather than a colored trailing edge, so the per-word blue tint is specific to this design.

Examples

Bubble or flat

There is no variant prop at runtime; flip the same two classes on the bubble you compose in "Compose the pair":

<div
  className={
    role === "user"
      ? "text-foreground/90 self-end text-end text-sm"
      : cn(paper, "max-w-[85%] self-end rounded-2xl px-3.5 py-2 text-sm")
  }
>

Where the reply comes from

s.message.status?.type is "running" while the assistant is still streaming and "complete" once it settles; drive any UI that depends on "is this message still arriving" from it directly instead of a local streaming flag.

const streaming = useAuiState((s) => s.message.status?.type === "running");

Restyle the pair

Both lanes take className on the root, and the shared paper and ghostButton tokens from surfaces.tsx style the bubble and the action buttons everywhere they're used.

<MessagePair className="max-w-none gap-3" /* ... */ />

API reference

Message parts

PartRendersNotes
MessagePrimitive.RootdivTracks hover for the action row; wrap each message in one.
MessagePrimitive.PartsfragmentRenders the message's content, including the streaming text.
ActionBarPrimitive.Rootdivautohide="always" hides until message.isHovering is true.
ActionBarPrimitive.CopybuttonDisabled while there's nothing to copy yet.
ActionBarPrimitive.ReloadbuttonDisabled while the thread is running or the message isn't from the assistant.

Message state

SelectorTypeDescription
s.message.role"user" | "assistant" | "system"Which half of the pair this message is.
s.message.status?.type"running" | "complete" | "incomplete" | "requires-action"Present on assistant messages; "running" while still streaming.
s.message.isHoveringbooleanSet by MessagePrimitive.Root from pointer enter and leave.
aui.message.reload()(config?) => voidRegenerates this assistant message as a new sibling branch.