Siteplane UI

Message

Lay out one turn of a conversation with the message, its actions and, in a chat between people, an avatar and a header.

Markdown
Which accounts are likely to churn this month?
Halden Logistics, Brightwave Studio and Ortega Foods dropped below half of their usual activity in the last six weeks. Each has a support conversation that has waited more than a week for a reply.
"use client";

import { RefreshCwIcon, ThumbsDownIcon, ThumbsUpIcon } from "lucide-react";
import * as React from "react";

import { Bubble, BubbleContent } from "@/siteplane/components/bubble";
import { Button } from "@/siteplane/components/button";
import { CopyButton } from "@/siteplane/components/copy-button";
import {
  Message,
  MessageContent,
  MessageFooter,
} from "@/siteplane/components/message";
import {
  Tooltip,
  TooltipPopup,
  TooltipTrigger,
} from "@/siteplane/components/tooltip";

const reply =
  "Halden Logistics, Brightwave Studio and Ortega Foods dropped below half of their usual activity in the last six weeks. Each has a support conversation that has waited more than a week for a reply.";

/** An icon action of the footer: the muted inline action with a tooltip. */
function Action({
  children,
  label,
  onClick,
  pressed,
}: {
  children: React.ReactNode;
  label: string;
  onClick?: () => void;
  pressed?: boolean;
}) {
  return (
    <Tooltip>
      <TooltipTrigger
        render={
          <Button
            aria-label={label}
            aria-pressed={pressed}
            onClick={onClick}
            profile="inline"
            profileTone="muted"
            shape="square"
            size="icon-xs"
            variant="text"
          >
            {children}
          </Button>
        }
      />
      <TooltipPopup aria-hidden>{label}</TooltipPopup>
    </Tooltip>
  );
}

export default function Example() {
  const [feedback, setFeedback] = React.useState<"good" | "bad" | null>(null);

  return (
    <div className="flex w-full max-w-md flex-col gap-8">
      <Message align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>
              Which accounts are likely to churn this month?
            </BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message>
        <MessageContent>
          <Bubble variant="ghost">
            <BubbleContent>{reply}</BubbleContent>
          </Bubble>
          <MessageFooter>
            <CopyButton labels={{ copy: "Copy response" }} value={reply} />
            <Action
              label="Good response"
              onClick={() => setFeedback(feedback === "good" ? null : "good")}
              pressed={feedback === "good"}
            >
              <ThumbsUpIcon
                aria-hidden="true"
                fill={feedback === "good" ? "currentColor" : "none"}
              />
            </Action>
            <Action
              label="Bad response"
              onClick={() => setFeedback(feedback === "bad" ? null : "bad")}
              pressed={feedback === "bad"}
            >
              <ThumbsDownIcon
                aria-hidden="true"
                fill={feedback === "bad" ? "currentColor" : "none"}
              />
            </Action>
            <Action label="Try again">
              <RefreshCwIcon aria-hidden="true" />
            </Action>
          </MessageFooter>
        </MessageContent>
      </Message>
    </div>
  );
}

Installation

pnpm dlx shadcn@4.18.0 add @siteplane/message

Complete 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/tooltip

Usage

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
        └── MessageFooter

Message 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.

Compare this month's active seats with September.
The usage data could not be loaded, so no months were compared.
"use client";

import { RefreshCwIcon } from "lucide-react";

import { Bubble, BubbleContent } from "@/siteplane/components/bubble";
import { Button } from "@/siteplane/components/button";
import { Message, MessageContent } from "@/siteplane/components/message";

export default function Example() {
  return (
    <div className="flex w-full max-w-md flex-col gap-8">
      <Message align="end">
        <MessageContent>
          <Bubble align="end" variant="muted">
            <BubbleContent>
              Compare this month's active seats with September.
            </BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message>
        <MessageContent>
          <Bubble variant="destructive">
            <BubbleContent>
              The usage data could not be loaded, so no months were compared.
            </BubbleContent>
          </Bubble>
          <Button className="self-start" size="sm" variant="outline">
            <RefreshCwIcon aria-hidden="true" />
            Try again
          </Button>
        </MessageContent>
      </Message>
    </div>
  );
}

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.

JW
Jonas Weber
The renewal call with Halden Logistics moved to Thursday at 10:00.
Works for me. I will update the invite.
ME
"use client";

import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "@/siteplane/components/avatar";
import { Bubble, BubbleContent } from "@/siteplane/components/bubble";
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageHeader,
} from "@/siteplane/components/message";

export default function Example() {
  return (
    <div className="flex w-full max-w-md flex-col gap-8">
      <Message>
        <MessageAvatar>
          <Avatar>
            {/* The header names the sender, so the portrait needs no text. */}
            <AvatarImage
              alt=""
              src="https://ui.siteplane.io/avatars/jonas.webp"
            />
            <AvatarFallback>JW</AvatarFallback>
          </Avatar>
        </MessageAvatar>
        <MessageContent>
          <MessageHeader>Jonas Weber</MessageHeader>
          <Bubble>
            <BubbleContent>
              The renewal call with Halden Logistics moved to Thursday at 10:00.
            </BubbleContent>
          </Bubble>
          <MessageFooter>9:41 AM</MessageFooter>
        </MessageContent>
      </Message>
      <Message align="end">
        <MessageContent>
          <Bubble align="end" variant="default">
            <BubbleContent>
              Works for me. I will update the invite.
            </BubbleContent>
          </Bubble>
          <MessageFooter>Delivered</MessageFooter>
        </MessageContent>
        <MessageAvatar>
          <Avatar>
            <AvatarImage
              alt="Mara Ellis"
              src="https://ui.siteplane.io/avatars/mara.webp"
            />
            <AvatarFallback>ME</AvatarFallback>
          </Avatar>
        </MessageAvatar>
      </Message>
    </div>
  );
}

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.

Jonas Weber
I checked the seat counts for the three accounts.
JW
Two of them are at 95 percent of their plan limit.
Then they may need the next plan.
I'll draft an offer for both today.
ME
"use client";

import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "@/siteplane/components/avatar";
import { Bubble, BubbleContent } from "@/siteplane/components/bubble";
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from "@/siteplane/components/message";

export default function Example() {
  return (
    <div className="flex w-full max-w-md flex-col gap-8">
      <MessageGroup>
        <Message>
          <MessageAvatar />
          <MessageContent>
            <MessageHeader>Jonas Weber</MessageHeader>
            <Bubble>
              <BubbleContent>
                I checked the seat counts for the three accounts.
              </BubbleContent>
            </Bubble>
          </MessageContent>
        </Message>
        <Message>
          <MessageAvatar>
            <Avatar>
              <AvatarImage
                alt=""
                src="https://ui.siteplane.io/avatars/jonas.webp"
              />
              <AvatarFallback>JW</AvatarFallback>
            </Avatar>
          </MessageAvatar>
          <MessageContent>
            <Bubble>
              <BubbleContent>
                Two of them are at 95 percent of their plan limit.
              </BubbleContent>
            </Bubble>
            <MessageFooter>10:02 AM</MessageFooter>
          </MessageContent>
        </Message>
      </MessageGroup>
      <MessageGroup>
        <Message align="end">
          <MessageContent>
            <Bubble align="end" variant="default">
              <BubbleContent>Then they may need the next plan.</BubbleContent>
            </Bubble>
          </MessageContent>
          <MessageAvatar />
        </Message>
        <Message align="end">
          <MessageContent>
            <Bubble align="end" variant="default">
              <BubbleContent>I'll draft an offer for both today.</BubbleContent>
            </Bubble>
            <MessageFooter>Delivered</MessageFooter>
          </MessageContent>
          <MessageAvatar>
            <Avatar>
              <AvatarImage
                alt="Mara Ellis"
                src="https://ui.siteplane.io/avatars/mara.webp"
              />
              <AvatarFallback>ME</AvatarFallback>
            </Avatar>
          </MessageAvatar>
        </Message>
      </MessageGroup>
    </div>
  );
}

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

UIMessageMessage
role: "user"align="end" with a muted Bubble and no avatar
role: "assistant"the start side with ghost Bubbles and no avatar
part texta Bubble with part.text
parts reasoning, tool-*, source-urla Trace above the reply
part filean 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

Component properties
PropTypeDefaultDescription
Message.align"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.
MessageGroupdiv props—Consecutive turns from one sender, 8 px apart. Give the earlier turns an empty MessageAvatar.
MessageAvatardiv 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.
MessageHeaderdiv props—A line above the content, such as the sender's name. It stays at the start. An empty header leaves no row.
MessageContentdiv props—The column of the header, the content and the footer, stacked with --sp-message-gap. Long words and URLs wrap.
MessageFooterdiv 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-gaplength8 pxThe space between the parts of MessageContent, and above the footer row that the avatar's slot keeps free.
--sp-message-avatar-gaplength8 pxThe space between the avatar column and the content.
--sp-message-avatar-sizelength32 pxThe least width of the avatar column, which an empty MessageAvatar keeps.
--sp-message-footer-sizelength24 pxThe 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.

Read the installed source.

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.