# Message

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

A question and an answer with message actions

[Complete TSX file](/examples/message/default.tsx)

```tsx
"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

Complete [one-time setup](/docs/installation/) first.

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/message
```

With npm: `npx shadcn@4.18.0 add @siteplane/message`. [Registry JSON](/registry/message.json) lists source files and dependencies.

For this page’s examples, also install:

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/avatar @siteplane/bubble @siteplane/button @siteplane/copy-button @siteplane/tooltip
```

## Usage

```tsx
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

```text
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](/docs/components/bubble/), and the scroll region around the turns is a [Message Scroller](/docs/components/message-scroller/). `MessageContent` stacks its parts 8 px apart. A reply that starts with the agent's steps in a [Trace](/docs/components/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](/docs/components/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](/docs/components/button/#bare-icon-actions) (`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.

A reply that failed, with Try again

[Complete TSX file](/examples/message/error.tsx)

```tsx
"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>
  );
}
```

## Avatars, header and footer

`MessageAvatar` holds an [Avatar](/docs/components/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`.

Two people with names, times and a delivery status

[Complete TSX file](/examples/message/people.tsx)

```tsx
"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](/docs/components/attachment/) 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](/docs/components/bubble/#groups).

Consecutive turns from two people

[Complete TSX file](/examples/message/group.tsx)

```tsx
"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

| 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](/docs/components/trace/) above the reply             |
| part `file`                               | an [Attachment](/docs/components/attachment/)                  |
| `regenerate({ messageId })`               | Try again in the footer                                        |
| `status === "error"`                      | a destructive Bubble and Try again, which calls `regenerate()` |

```tsx
"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.

```tsx
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.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. |
| MessageGroup | div props | — | Consecutive turns from one sender, 8 px apart. Give the earlier turns an empty MessageAvatar. |
| MessageAvatar | 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. |
| MessageHeader | div props | — | A line above the content, such as the sender's name. It stays at the start. An empty header leaves no row. |
| MessageContent | div props | — | The column of the header, the content and the footer, stacked with --sp-message-gap. Long words and URLs wrap. |
| MessageFooter | 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.

[Read the installed source](/source/message).

## 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](/docs/components/message-scroller/#announcements).

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.
