Message
Lay out one turn of a conversation with the message, its actions and, in a chat between people, an avatar and a header.
Installation
pnpm dlx shadcn@4.18.0 add @siteplane/messageComplete the one-time setup first. Registry JSON lists source files and dependencies.
For this page’s examples, also install:
pnpm dlx shadcn@4.18.0 add @siteplane/avatar @siteplane/bubble @siteplane/button @siteplane/copy-button @siteplane/tooltipUsage
import { Bubble, BubbleContent } from "@/siteplane/components/bubble";
import { Message, MessageContent } from "@/siteplane/components/message";
<Message align="end">
<MessageContent>
<Bubble align="end">
<BubbleContent>
Which accounts are likely to churn this month?
</BubbleContent>
</Bubble>
</MessageContent>
</Message>;Composition
MessageGroup (optional, for consecutive turns)
└── Message
├── MessageAvatar
└── MessageContent
├── MessageHeader
├── Bubble, Trace, Attachment or other content
└── MessageFooterMessage lays out a turn and draws no surface of its own. The text sits on a Bubble, and the scroll region around the turns is a Message Scroller. MessageContent stacks its parts 8 px apart. A reply that starts with the agent's steps in a Trace sets --sp-message-gap to 16 px on its Message, so the answer stands apart from the steps, which keep their own 8 px.
Message actions
Put the actions of a turn in MessageFooter. The reply at the top of this page keeps four actions visible. A Copy Button named “Copy response” comes first, then Good response and Bad response, which toggle with aria-pressed, and Try again last. Each is the muted inline action of Button (profile="inline", profileTone="muted", variant="text") in a 24 px square, with a tooltip that repeats its aria-label and is hidden from assistive technology. A pressed thumb fills its icon, so the choice is visible without relying on color.
Error turn
When a reply fails, say what failed on a destructive Bubble and put a Try again Button under it. The turn has no feedback actions, since there is no reply to rate.
Avatars, header and footer
MessageAvatar holds an Avatar. The avatar ends with the last bubble and sits above a footer, so it stays beside the message it belongs to; a turn shorter than its avatar grows to fit it. MessageHeader is a line above the content for the sender's name, and MessageFooter a line under it for a time or a delivery status, both in 12 px text in the tertiary color. In a turn with a framed bubble they start where the bubble's text starts; with a ghost bubble they start at the edge of the content. When the header names the sender, give the portrait an empty alt.
End-aligned turns
align="end" puts a turn on the other side of the conversation, for the person who is writing. The avatar and the content move to the end and the footer follows them, while the header stays at the start. Give a Bubble in the turn the same align. An Attachment Group in an end-aligned turn is only as wide as its cards, so sent files sit at the end like the bubble, and a row wider than the turn still scrolls.
In an AI chat, the person's turns are end-aligned muted bubbles and the assistant's replies are ghost bubbles at the start, both without an avatar, as in the example at the top of this page. The layout already shows who is speaking, so avatars are for chats between people, such as a support inbox or a team chat. In a right-to-left page, both sides mirror.
Groups
Wrap consecutive turns from one sender in MessageGroup, which stacks them 8 px apart. Give the earlier turns an empty MessageAvatar: it keeps the avatar's column, so the bubbles of the group line up, and only the last turn shows the avatar. A turn with no MessageAvatar at all leaves no column behind. To stack several bubbles inside one turn instead, use a Bubble Group.
AI SDK
A UIMessage of the AI SDK maps to one Message. Its role decides the side, and its parts fill MessageContent in their order. The examples on this page show fixed turns; the code below builds them from the messages of useChat instead.
Message parts
| UIMessage | Message |
|---|---|
role: "user" | align="end" with a muted Bubble and no avatar |
role: "assistant" | the start side with ghost Bubbles and no avatar |
part text | a Bubble with part.text |
parts reasoning, tool-*, source-url | a Trace above the reply |
part file | an Attachment |
regenerate({ messageId }) | Try again in the footer |
status === "error" | a destructive Bubble and Try again, which calls regenerate() |
"use client";
import type { UIMessage } from "ai";
import { Bubble, BubbleContent } from "@/siteplane/components/bubble";
import { CopyButton } from "@/siteplane/components/copy-button";
import {
Message,
MessageContent,
MessageFooter,
} from "@/siteplane/components/message";
export function ChatMessage({
message,
streaming = false,
}: {
message: UIMessage;
streaming?: boolean;
}) {
const user = message.role === "user";
const text = message.parts
.map((part) => (part.type === "text" ? part.text : ""))
.join("");
return (
<Message align={user ? "end" : "start"}>
<MessageContent>
{message.parts.map((part, index) =>
part.type === "text" ? (
<Bubble
align={user ? "end" : "start"}
key={index}
variant={user ? "muted" : "ghost"}
>
<BubbleContent>{part.text}</BubbleContent>
</Bubble>
) : null,
)}
{user || streaming ? null : (
<MessageFooter>
<CopyButton labels={{ copy: "Copy response" }} value={text} />
{/* Good response, Bad response and Try again, as at the top of this page. */}
</MessageFooter>
)}
</MessageContent>
</Message>
);
}Try again and errors
Try again in the footer of a reply calls regenerate({ messageId: message.id }) of useChat. When a request fails, status is "error" and error is set. Show the error turn after the last message, with Try again calling regenerate(), and write a short message of your own there rather than error.message, which can hold details from the server.
const { error, regenerate } = useChat();
// After the last message of the thread:
error ? (
<Message>
<MessageContent>
<Bubble variant="destructive">
<BubbleContent>The reply could not be loaded.</BubbleContent>
</Bubble>
<Button
className="self-start"
onClick={() => regenerate()}
size="sm"
variant="outline"
>
<RefreshCwIcon aria-hidden="true" />
Try again
</Button>
</MessageContent>
</Message>
) : null;API reference
| Prop | Type | Default | Description |
|---|---|---|---|
Message. | "start" | "end" | "start" | The side of the conversation. end mirrors the turn: the avatar, the content and the footer move to the end, and the header stays at the start. Give a Bubble in the turn the same align. |
Message | div props | — | Consecutive turns from one sender, 8 px apart. Give the earlier turns an empty MessageAvatar. |
Message | div props | — | The avatar column. The avatar ends with the last bubble, above a footer. An empty one keeps the column; a turn without one has no column. |
Message | div props | — | A line above the content, such as the sender's name. It stays at the start. An empty header leaves no row. |
Message | div props | — | The column of the header, the content and the footer, stacked with --sp-message-gap. Long words and URLs wrap. |
Message | div props | — | A line under the content: a time, a delivery status or the actions of the turn. It follows the side of the turn. An empty footer leaves no row. |
--sp-message-gap | length | 8 px | The space between the parts of MessageContent, and above the footer row that the avatar's slot keeps free. |
--sp-message-avatar-gap | length | 8 px | The space between the avatar column and the content. |
--sp-message-avatar-size | length | 32 px | The least width of the avatar column, which an empty MessageAvatar keeps. |
--sp-message-footer-size | length | 24 px | The least height of the footer, which the avatar's slot keeps free below the avatar. Set it to the height of a taller footer. |
Every part renders a div and accepts its props, a ref and className. Set the
custom properties on a Message or on the thread around it, for example with
className="[--sp-message-gap:var(--sp-rw-space-4)]". The header and the footer
start at the inline padding of a framed Bubble, which they read from
--sp-bubble-padding-inline. The type MessageAlign is exported.
Usage notes
Message lays out a turn and keeps no state. Selection, editing, reactions and read receipts belong to the application, and the conversation's semantics belong to the thread around the turns, such as the log of a Message Scroller.
Give each icon action in the footer an aria-label. A footer that waits for its actions can mount empty: an empty header or footer leaves no row and no gap behind.