Skip to main content

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

message
Message
required
The message object containing content, metadata, and status
isOutgoing
boolean
required
Whether this message was sent by the current user (vs received)
showAvatar
boolean
Whether to display the contact’s avatar (typically shown on first message in a group)
contactInitials
string
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>

Performance optimization

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

Build docs developers (and LLMs) love