Overview
The MessageBubble component renders a single message in the chat interface. It supports text messages, image attachments, message reactions, reply indicators, forwarded labels, and delivery status icons.
Usage
import { MessageBubble } from "@/components/chat/message-bubble"
function MessageList() {
return (
<div>
{messages.map((message) => (
<MessageBubble
key={message.id}
message={message}
isOutgoing={message.authorId === currentUserId}
showAvatar={shouldShowAvatar(message)}
contactInitials={getInitials(contact.name)}
onMediaPreview={handleMediaClick}
/>
))}
</div>
)
}
Props
The message object containing content, metadata, and status
Whether this message was sent by the current user (vs received)
Whether to display the contact’s avatar (typically shown on first message in a group)
Contact initials to display in avatar fallback (for incoming messages)
onMediaPreview
(media: MediaAttachment) => void
Callback when user clicks on an image attachment
Type definitions
interface MessageBubbleProps {
message: Message
isOutgoing: boolean
showAvatar?: boolean
contactInitials?: string
onMediaPreview?: (media: MediaAttachment) => void
}
interface Message {
id: string
chatId: string
authorId: string
contentType: "text" | "image" | "audio"
text?: string
media?: MediaAttachment
status: "queued" | "sending" | "sent" | "delivered" | "read" | "error"
createdAt: string
updatedAt?: string
replyToId?: string
isForwarded?: boolean
reactions?: Reaction[]
}
interface MediaAttachment {
id: string
type: "image" | "audio"
url: string
thumbnailUrl?: string
width?: number
height?: number
sizeInBytes?: number
caption?: string
waveform?: number[]
localObjectUrl?: string
}
interface Reaction {
emoji: string
authorId: string
createdAt: string
}
Message status indicators
Outgoing messages display status icons:
const statusIcon = {
queued: ClockCountdown,
sending: Clock,
sent: Check,
delivered: Checks,
read: Checks,
error: WarningCircle,
}
const statusColor = {
queued: "text-muted-foreground",
sending: "text-muted-foreground",
sent: "text-muted-foreground",
delivered: "text-status-delivered",
read: "text-status-read",
error: "text-destructive",
}
Status icons use Phosphor Icons. The “read” and “delivered” statuses use filled double-check icons, while other statuses use regular weight.
Message layout
The component renders different layouts based on message direction:
Outgoing messages
// Aligned to the right
<div className="flex gap-2 justify-end">
<div className="max-w-[82%] md:max-w-[68%] items-end">
<div className="rounded-3xl rounded-br-lg bg-bubble-outgoing text-foreground">
{/* Message content */}
</div>
</div>
</div>
Incoming messages
// Aligned to the left with avatar
<div className="flex gap-2 justify-start">
{showAvatar ? (
<div className="mt-auto h-7 w-7 shrink-0 rounded-full bg-secondary text-xs font-semibold uppercase text-secondary-foreground">
<span className="flex h-full w-full items-center justify-center">
{contactInitials}
</span>
</div>
) : (
<span className="w-7" aria-hidden />
)}
<div className="max-w-[82%] md:max-w-[68%]">
<div className="rounded-3xl rounded-bl-lg bg-bubble-incoming text-foreground">
{/* Message content */}
</div>
</div>
</div>
Image attachments
Image messages display a preview with optional caption:
{message.contentType === "image" && message.media ? (
<button
type="button"
onClick={() => onMediaPreview?.(message.media as MediaAttachment)}
className="group mb-2 block overflow-hidden rounded-2xl border border-border/40 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
>
<Image
src={message.media.url}
alt={message.media.caption ?? "Shared media"}
width={message.media.width ?? 640}
height={message.media.height ?? 360}
className="h-auto max-h-[320px] w-full object-cover transition duration-500 group-hover:scale-[1.02]"
/>
{message.media.caption ? (
<p className="px-3 pb-2 pt-2 text-sm text-foreground/90">
{message.media.caption}
</p>
) : null}
</button>
) : null}
Images include a subtle zoom effect on hover (scale-[1.02]) for better interactivity.
Reply indicator
Messages with replyToId display a reply indicator:
{message.replyToId ? (
<div className="mb-2 rounded-2xl bg-black/5 px-3 py-2 text-xs text-muted-foreground">
Replying to message
</div>
) : null}
Reactions
Multiple users can react to a message with emojis:
{message.reactions?.length ? (
<div className="mt-2 inline-flex items-center gap-1 rounded-full bg-black/10 px-2 py-0.5 text-xs text-foreground/80">
{message.reactions.map((reaction) => (
<span key={reaction.authorId + reaction.emoji}>{reaction.emoji}</span>
))}
</div>
) : null}
Forwarded label
Forwarded messages display a small label:
{message.isForwarded ? (
<span className="mt-1 inline-flex items-center gap-1 text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
Forwarded
</span>
) : null}
<footer className="mt-1 flex items-center gap-1 text-[11px] text-muted-foreground">
<span>{format(new Date(message.createdAt), "HH:mm")}</span>
{isOutgoing ? (
<StatusIcon
className={cn(
"h-3.5 w-3.5",
message.status === "read" ? "text-status-read" : statusColor[message.status]
)}
weight={message.status === "read" || message.status === "delivered" ? "fill" : "regular"}
aria-label={`Message ${message.status}`}
/>
) : null}
</footer>
The component is wrapped with React.memo for performance:
export const MessageBubble = memo(MessageBubbleComponent)
MessageBubble.displayName = "MessageBubble"
The memo wrapper prevents unnecessary re-renders when parent components update but message props haven’t changed.
Styling
Bubble corners are asymmetrically rounded for a modern chat interface:
- Outgoing: rounded-3xl with rounded-br-lg (sharp bottom-right)
- Incoming: rounded-3xl with rounded-bl-lg (sharp bottom-left)
const bubbleClasses = cn(
"relative rounded-3xl px-4 py-2 shadow-sm transition-colors",
isOutgoing
? "rounded-br-lg bg-bubble-outgoing text-foreground"
: "rounded-bl-lg bg-bubble-incoming text-foreground"
)
Accessibility
- Status icons include
aria-label attributes
- Spacer elements use
aria-hidden
- Images have descriptive alt text
- Clickable media has focus-visible ring