Skip to content

Pro pre-sale is open. Installation opens January 2027. See Pro

Components

MessageBubble

One message in a conversation: the bubble with its text, an optional avatar and author, the quote of the message it answers, reactions, a footer with the time, edited, pinned and the delivery status, and a toolbar of actions on hover. Put messages in a MessageList; use plain for long replies from an assistant.

  • npm: 2 direct, 5 total
  • registry: 3 direct, 5 total

Preview

Linh Tran
Can you send the brief before the call?

Props

  • side
  • variant
  • edited
  • pinned
  • forwarded
  • grouped
  • text
  • status
  • showAuthor
  • showAvatar
  • showTime
  • showToolbar
  • showReactions
  • showReply
import { Avatar } from "@/components/ui/avatar"
import { MessageActions } from "@/components/ui/message-actions"
import { MessageBubble } from "@/components/ui/message-bubble"

const addReaction = (emoji: string) => { /* Add the reader's reaction to the message. */ }
const toggleReaction = (emoji: string) => { /* Add or remove the reader's reaction. */ }
const replyToMessage = () => { /* Put the message in the Composer's replyTo. */ }
const forwardMessage = () => { /* Open the forward dialog. */ }
const setPinned = (pinned: boolean) => { /* Pin or unpin the message. */ }
const startSelection = () => { /* Turn on selection mode with this message picked. */ }
const sampleReactions = [{ emoji: "👍", count: 3, reacted: true }, { emoji: "🎉", count: 1 }]

<MessageBubble value="m1" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:42" reactions={sampleReactions} onReactionToggle={toggleReaction} toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} />} className="max-w-md pt-xl">Can you send the brief before the call?</MessageBubble>

Usage

When to use

  • Show one message in a chat between people, or with an assistant, with its sender, time and status.
  • Group messages that follow one from the same sender.

When not to use

  • A comment on a page or a review. Use Card or plain text.
  • A short status line in the interface. Use Alert or Toast.
  • A tip on a control. Use Tooltip.

Variants

side

start for messages from others, end for the reader's own.

  • Linh Tran
    Can you send the brief before the call?

    startdefault

  • Linh Tran
    Can you send the brief before the call?

    end

import { Avatar } from "@/components/ui/avatar"
import { MessageActions } from "@/components/ui/message-actions"
import { MessageBubble } from "@/components/ui/message-bubble"

const addReaction = (emoji: string) => { /* Add the reader's reaction to the message. */ }
const toggleReaction = (emoji: string) => { /* Add or remove the reader's reaction. */ }
const replyToMessage = () => { /* Put the message in the Composer's replyTo. */ }
const forwardMessage = () => { /* Open the forward dialog. */ }
const setPinned = (pinned: boolean) => { /* Pin or unpin the message. */ }
const startSelection = () => { /* Turn on selection mode with this message picked. */ }
const sampleReactions = [{ emoji: "👍", count: 3, reacted: true }, { emoji: "🎉", count: 1 }]

<MessageBubble value="m1" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:42" reactions={sampleReactions} onReactionToggle={toggleReaction} toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} />} className="max-w-md pt-xl">Can you send the brief before the call?</MessageBubble>
<MessageBubble side="end" value="m1" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:42" reactions={sampleReactions} onReactionToggle={toggleReaction} toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} />} className="max-w-md pt-xl">Can you send the brief before the call?</MessageBubble>

variant

default: a neutral bubble with an edge. accent: the reader's own messages in a chat between people. plain: no fill, for a long reply from an assistant.

  • Linh Tran
    Can you send the brief before the call?

    defaultdefault

  • Linh Tran
    Can you send the brief before the call?

    accent

  • Linh Tran
    Can you send the brief before the call?

    plain

import { Avatar } from "@/components/ui/avatar"
import { MessageActions } from "@/components/ui/message-actions"
import { MessageBubble } from "@/components/ui/message-bubble"

const addReaction = (emoji: string) => { /* Add the reader's reaction to the message. */ }
const toggleReaction = (emoji: string) => { /* Add or remove the reader's reaction. */ }
const replyToMessage = () => { /* Put the message in the Composer's replyTo. */ }
const forwardMessage = () => { /* Open the forward dialog. */ }
const setPinned = (pinned: boolean) => { /* Pin or unpin the message. */ }
const startSelection = () => { /* Turn on selection mode with this message picked. */ }
const sampleReactions = [{ emoji: "👍", count: 3, reacted: true }, { emoji: "🎉", count: 1 }]

<MessageBubble value="m1" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:42" reactions={sampleReactions} onReactionToggle={toggleReaction} toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} />} className="max-w-md pt-xl">Can you send the brief before the call?</MessageBubble>
<MessageBubble variant="accent" value="m1" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:42" reactions={sampleReactions} onReactionToggle={toggleReaction} toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} />} className="max-w-md pt-xl">Can you send the brief before the call?</MessageBubble>
<MessageBubble variant="plain" value="m1" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:42" reactions={sampleReactions} onReactionToggle={toggleReaction} toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} />} className="max-w-md pt-xl">Can you send the brief before the call?</MessageBubble>

status

The delivery status of the reader's own message: sending, sent (the server has it), delivered (it reached the other device), read (seen) or failed.

  • Linh Tran
    Can you send the brief before the call?
    Sending

    sending

  • Linh Tran
    Can you send the brief before the call?
    Sent

    sent

  • Linh Tran
    Can you send the brief before the call?
    Delivered

    delivered

  • Linh Tran
    Can you send the brief before the call?
    Seen

    read

  • Linh Tran
    Can you send the brief before the call?
    Not sent

    failed

import { Avatar } from "@/components/ui/avatar"
import { MessageActions } from "@/components/ui/message-actions"
import { MessageBubble } from "@/components/ui/message-bubble"

const retrySend = () => { /* Send the message again. */ }
const addReaction = (emoji: string) => { /* Add the reader's reaction to the message. */ }
const toggleReaction = (emoji: string) => { /* Add or remove the reader's reaction. */ }
const replyToMessage = () => { /* Put the message in the Composer's replyTo. */ }
const forwardMessage = () => { /* Open the forward dialog. */ }
const setPinned = (pinned: boolean) => { /* Pin or unpin the message. */ }
const startSelection = () => { /* Turn on selection mode with this message picked. */ }
const sampleReactions = [{ emoji: "👍", count: 3, reacted: true }, { emoji: "🎉", count: 1 }]

<MessageBubble value="m1" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:42" status="sending" reactions={sampleReactions} onReactionToggle={toggleReaction} toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} />} className="max-w-md pt-xl">Can you send the brief before the call?</MessageBubble>
<MessageBubble value="m1" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:42" status="sent" reactions={sampleReactions} onReactionToggle={toggleReaction} toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} />} className="max-w-md pt-xl">Can you send the brief before the call?</MessageBubble>
<MessageBubble value="m1" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:42" status="delivered" reactions={sampleReactions} onReactionToggle={toggleReaction} toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} />} className="max-w-md pt-xl">Can you send the brief before the call?</MessageBubble>
<MessageBubble value="m1" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:42" status="read" reactions={sampleReactions} onReactionToggle={toggleReaction} toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} />} className="max-w-md pt-xl">Can you send the brief before the call?</MessageBubble>
<MessageBubble value="m1" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:42" status="failed" onRetry={retrySend} reactions={sampleReactions} onReactionToggle={toggleReaction} toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} />} className="max-w-md pt-xl">Can you send the brief before the call?</MessageBubble>

Examples

Two people

Linh Tran
Can you send the brief before the call?
Sure, sending it now.
Seen
import { Avatar } from "@/components/ui/avatar"
import { MessageBubble } from "@/components/ui/message-bubble"

<div className="flex w-full max-w-md flex-col gap-md">
  <MessageBubble author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:41">Can you send the brief before the call?</MessageBubble>
  <MessageBubble side="end" variant="accent" time="10:42" status="read">Sure, sending it now.</MessageBubble>
</div>

Grouped messages

Messages that follow one from the same author drop the name and avatar and sit closer.

Linh Tran
The brief looks good.
Linh Tran
One question about the timeline, though.
import { Avatar } from "@/components/ui/avatar"
import { MessageBubble } from "@/components/ui/message-bubble"

<div className="flex w-full max-w-md flex-col gap-md">
  <MessageBubble author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>}>The brief looks good.</MessageBubble>
  <MessageBubble grouped author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:45">One question about the timeline, though.</MessageBubble>
</div>

Delivery states

The reader's own messages show where they are: sending, sent, delivered, seen, or not sent with a way to retry.

On my way.
Sending
Running five minutes late.
Sent
Order me a coffee?
Delivered
Thanks!
Seen
See you at 3.
Not sent
import { MessageBubble } from "@/components/ui/message-bubble"

const retrySend = () => { /* Send the message again. */ }

<div className="flex w-full max-w-md flex-col gap-md">
  <MessageBubble side="end" variant="accent" time="10:46" status="sending">On my way.</MessageBubble>
  <MessageBubble side="end" variant="accent" time="10:46" status="sent">Running five minutes late.</MessageBubble>
  <MessageBubble side="end" variant="accent" time="10:47" status="delivered">Order me a coffee?</MessageBubble>
  <MessageBubble side="end" variant="accent" time="10:47" status="read">Thanks!</MessageBubble>
  <MessageBubble side="end" variant="accent" time="10:48" status="failed" onRetry={retrySend}>See you at 3.</MessageBubble>
</div>

Actions on hover

Hover or focus the message to show its toolbar: react, reply, forward, pin and select.

Linh Tran
Can you send the brief before the call?
import { Avatar } from "@/components/ui/avatar"
import { MessageActions } from "@/components/ui/message-actions"
import { MessageBubble } from "@/components/ui/message-bubble"

const addReaction = (emoji: string) => { /* Add the reader's reaction to the message. */ }
const replyToMessage = () => { /* Put the message in the Composer's replyTo. */ }
const forwardMessage = () => { /* Open the forward dialog. */ }
const setPinned = (pinned: boolean) => { /* Pin or unpin the message. */ }
const startSelection = () => { /* Turn on selection mode with this message picked. */ }

<MessageBubble value="m1" author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:41" toolbar={<MessageActions onReact={addReaction} onReply={replyToMessage} onForward={forwardMessage} onPinnedChange={setPinned} onSelect={startSelection} />} className="max-w-md pt-xl">Can you send the brief before the call?</MessageBubble>

Reactions, reply and more

A reply quotes the message it answers; reactions toggle; edited, pinned and forwarded show as notes.

Linh Tran
Forwarded
The launch moved to Friday.
Reply to Linh TranCan you send the brief before the call?
Then we ship the brief on Thursday.
EditedPinnedSeen
import { Avatar } from "@/components/ui/avatar"
import { MessageBubble } from "@/components/ui/message-bubble"

const toggleReaction = (emoji: string) => { /* Add or remove the reader's reaction. */ }
const sampleReactions = [{ emoji: "👍", count: 3, reacted: true }, { emoji: "🎉", count: 1 }]
const sampleReply = { author: "Linh Tran", text: "Can you send the brief before the call?" }

<div className="flex w-full max-w-md flex-col gap-md">
  <MessageBubble forwarded author="Linh Tran" avatar={<Avatar alt="" color="blue">LT</Avatar>} time="10:41" reactions={sampleReactions} onReactionToggle={toggleReaction}>The launch moved to Friday.</MessageBubble>
  <MessageBubble side="end" variant="accent" edited pinned time="10:43" status="read" replyTo={sampleReply}>Then we ship the brief on Thursday.</MessageBubble>
</div>

Assistant reply

Plain has no fill, so a long reply reads as page text, with actions under it.

Assistant
The report covers three regions. Sales grew in two of them, and the third held steady.
import { IconCopy, IconSparkles } from "@tabler/icons-react"
import { Avatar } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { MessageBubble } from "@/components/ui/message-bubble"

<MessageBubble variant="plain" author="Assistant" avatar={<Avatar alt="">
  <IconSparkles size={16} />
</Avatar>} actions={<Button size="sm" variant="ghost" iconOnly aria-label="Copy">
  <IconCopy size={16} />
</Button>} className="max-w-md">The report covers three regions. Sales grew in two of them, and the third held steady.</MessageBubble>

Do and don't

Do

  • Use side end with variant accent for the reader's own messages in a chat between people, and side start with default for everyone else.
  • Use variant plain for an assistant's replies, which are often long and hold lists or code.
  • Set grouped on messages that follow one from the same author within a few minutes.
  • Pass a MessageActions as toolbar with only the actions the conversation supports, and show reactions under the bubble.
  • Keep a failed message in place with status failed and onRetry, instead of removing it.

Don't

  • Do not put a Card or another bubble inside a bubble.
  • Do not show the time on every grouped message; show it on the last one of the group.
  • Do not tell the sides apart by color only; keep start and end.
  • Do not hide an action behind hover alone: MessageActions also shows on focus and on a tap.

Props

NameTypeValuesDefaultDescription
valuestring--Identifies the message for selection in a MessageList.
sideenumstart | endstartstart for messages from others, end for the reader's own.
variantenumdefault | accent | plaindefaultdefault: a neutral bubble with an edge. accent: the reader's own messages in a chat between people. plain: no fill, for a long reply from an assistant.
authornode--The sender's name above the bubble. Hidden on a grouped message, but still read.
avatarnode--An Avatar beside the bubble. Pass alt="" when author names the sender.
timenode--The time as shown, such as 10:42.
dateTimestring--The machine-readable time for the time element, such as 2026-10-23T10:42.
statusenumsending | sent | delivered | read | failed-The delivery status of the reader's own message: sending, sent (the server has it), delivered (it reached the other device), read (seen) or failed.
onRetry() => void--With status failed: shows a Retry button that calls it.
editedboolean-falseThe message was changed after it was sent: Edited shows in the footer.
pinnedboolean-falseThe message is pinned in the conversation: a pin shows in the footer.
forwardedboolean-falseThe message was forwarded from another conversation: Forwarded shows above it.
replyTo{ author: React.ReactNode; text: React.ReactNode; onClick?: () => void }--The message this one answers, quoted above the bubble. onClick makes the quote a button that scrolls to it.
reactions{ emoji: string; count: number; reacted?: boolean }[]--Reactions under the bubble, as chips with counts. reacted marks the reader's own.
onReactionToggle(emoji: string) => void--Makes each reaction chip a toggle button that adds or removes the reader's reaction.
toolbarnode--A MessageActions shown over the bubble on hover, focus or a tap. Hidden in selection mode.
actionsnode--Controls in the footer, such as a Copy button. Use ghost Buttons in size sm. For actions on hover, use toolbar.
groupedboolean-falseContinues the message above from the same author: no name or avatar, and a smaller gap.
labels{ sending?: string; sent?: string; delivered?: string; read?: string; failed?: string; retry?: string; edited?: string; pinned?: string; forwarded?: string; replyTo?: string; select?: string; reaction?: (emoji: string, count: number) => string }--Every word the message shows or reads out. Defaults: Sending, Sent, Delivered, Seen, Not sent, Retry, Edited, Pinned, Forwarded, Reply to, Select message. Vietnamese: Đang gửi, Đã gửi, Đã nhận, Đã xem, Chưa gửi được, Thử lại, Đã sửa, Đã ghim, Đã chuyển tiếp, Trả lời, Chọn tin nhắn.
children requirednode--The message: text, or text with links and AttachmentPreview tiles.

Anatomy

PartDescription
rootOne row: the selection checkbox, the avatar column, then the body. Reversed for side end. In selection mode it is padded and rounded, and takes the muted accent fill when picked.
selectThe Checkbox of a message in selection mode, in a box one line tall.
avatarOptional column for an Avatar at the outer edge. Keeps its width on a grouped message, so the bubbles line up.
bodyThe author, the notes, the bubble, the reactions and the footer, stacked. At most 80% of the row, except plain.
authorThe sender's name above the bubble.
forwardedA note above a forwarded message: a 12px forward icon and the word Forwarded.
replyThe quote of the message answered: its author and one line of it, behind a strong start edge. A button with onClick.
bubbleThe message text. Keeps the line breaks the sender typed and wraps long words.
toolbarThe MessageActions passed in toolbar, floating over the bubble's outer top corner.
reactionsThe row of reaction chips under the bubble. It wraps.
reactionOne chip: the emoji and its count, 24px tall with a 44px touch target. Pressed, in the muted accent fill, when the reader reacted.
footerThe time, edited, the pin, the status, Retry and the actions under the bubble.
statusThe delivery status: a 12px icon with its words for screen readers, or the visible words for a failed message.
iconThe status icon: clock while sending, one check when sent, two checks when delivered, two checks in the accent icon color when seen, alert circle when it failed.
retryThe underlined text button that sends a failed message again.
actionsOptional controls in the footer, such as a Copy button.

Accessibility

Role: generic

KeysAction
TabMoves to the quote, the toolbar, the reactions, Retry and the footer actions, in that order. The toolbar is one tab stop.
Arrow Left / Arrow RightIn the toolbar, moves between its buttons (MessageActions).
SpaceIn selection mode, on the checkbox, picks the message or puts it back. Shift with a click picks a range.
  • The author name comes before the text, so a screen reader hears who wrote it first. On a grouped message the name is hidden but still read.
  • Pass alt="" to the Avatar when author names the same person, so the name is not read twice.
  • The time is a time element; pass dateTime with the machine-readable value.
  • The status icon is hidden from screen readers; the status is read in words from labels (EN `Sent`, `Delivered`, `Seen`, VI `Đã gửi`, `Đã nhận`, `Đã xem`). A failed message shows its words, EN `Not sent`, in the danger color, with a Retry button when onRetry is set.
  • Seen and delivered share the two-check icon; seen takes the accent icon color, and the words tell them apart for screen readers.
  • The quote of the message answered is read with EN `Reply to` before its author. With onClick it is a button that scrolls to that message.
  • Each reaction chip is a toggle button with aria-pressed, named EN `👍 2`; without onReactionToggle the chips are text.
  • The toolbar shows on hover, while anything in the message has focus, while its emoji popover is open, and after a tap on a touch screen. It is never the only way to an action: keyboard focus reveals it.
  • In selection mode each message gets a checkbox named EN `Select message` and described by the message text; a press anywhere on the row toggles it, and the picked row takes the muted accent fill.
  • Color never carries the side on its own: the bubble also sits at the start or the end of the row.

Install

npx werewolf-ui add message-bubble

Run npx werewolf-ui init once in your app before adding components. Init sets up tokens, their CSS import and agent rules. Add installs this component and its dependencies while preserving your theme. See Installation for requirements and the alternative registry setup.