HextaUI

Message

A chat message row with an avatar, name, bubbles and status, where new messages rise in from the sender's side.

PS
Preet
Did the message component land?
Send me something and see how it arrives.
"use client"

import * as React from "react"
import { IconArrowUp, IconChecks } from "@tabler/icons-react"

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupTextarea,
} from "@/components/ui/input-group"
import { Marker, MarkerContent, MarkerTime } from "@/components/ui/marker"
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from "@/components/ui/message"

type Entry = { id: number; from: "me" | "preet"; text: string }

const replies = [
  "Ha, that rise-in is nice.",
  "Try sending a few in a row.",
  "Each one comes in from your side.",
]

export function MessageDemo() {
  const [entries, setEntries] = React.useState<Entry[]>([
    { id: 1, from: "preet", text: "Did the message component land?" },
    { id: 2, from: "preet", text: "Send me something and see how it arrives." },
  ])
  const [draft, setDraft] = React.useState("")
  const nextId = React.useRef(3)
  const replyIndex = React.useRef(0)
  const replyTimer = React.useRef<ReturnType<typeof setTimeout>>(undefined)
  const threadRef = React.useRef<HTMLDivElement>(null)

  React.useEffect(() => () => clearTimeout(replyTimer.current), [])

  React.useEffect(() => {
    const thread = threadRef.current
    if (thread) {
      thread.scrollTo({ top: thread.scrollHeight, behavior: "smooth" })
    }
  }, [entries])

  const send = () => {
    const text = draft.trim()
    if (!text) {
      return
    }
    setEntries((current) => [
      ...current,
      { id: nextId.current++, from: "me", text },
    ])
    setDraft("")
    clearTimeout(replyTimer.current)
    replyTimer.current = setTimeout(() => {
      const reply = replies[replyIndex.current % replies.length]
      replyIndex.current += 1
      setEntries((current) => [
        ...current,
        { id: nextId.current++, from: "preet", text: reply },
      ])
    }, 900)
  }

  const runs = entries.reduce<Entry[][]>((groups, entry) => {
    const last = groups.at(-1)
    if (last && last[0].from === entry.from) {
      last.push(entry)
    } else {
      groups.push([entry])
    }
    return groups
  }, [])

  return (
    <div className="flex w-full max-w-md flex-col overflow-hidden rounded-xl border">
      <div
        ref={threadRef}
        className="h-80 overflow-y-auto overscroll-none px-4 py-4"
      >
        <MessageGroup>
          <Marker variant="separator">
            <MarkerContent>
              <MarkerTime date={new Date()} />
            </MarkerContent>
          </Marker>
          {runs.map((run, index) => {
            const mine = run[0].from === "me"
            const lastRun = index === runs.length - 1
            return (
              <Message key={run[0].id} align={mine ? "end" : "start"}>
                {!mine && (
                  <MessageAvatar>
                    <Avatar>
                      <AvatarImage
                        src="https://github.com/preetsuthar17.png"
                        alt=""
                      />
                      <AvatarFallback>PS</AvatarFallback>
                    </Avatar>
                  </MessageAvatar>
                )}
                <MessageContent>
                  {!mine && <MessageHeader>Preet</MessageHeader>}
                  <BubbleGroup shape="tail">
                    {run.map((entry) => (
                      <Bubble
                        key={entry.id}
                        variant={mine ? "default" : "secondary"}
                      >
                        <BubbleContent>{entry.text}</BubbleContent>
                      </Bubble>
                    ))}
                  </BubbleGroup>
                  {mine && lastRun && (
                    <MessageFooter>
                      <IconChecks />
                      Read
                    </MessageFooter>
                  )}
                </MessageContent>
              </Message>
            )
          })}
        </MessageGroup>
      </div>
      <form
        className="border-t p-3"
        onSubmit={(event) => {
          event.preventDefault()
          send()
        }}
      >
        <InputGroup>
          <InputGroupTextarea
            value={draft}
            onChange={(event) => setDraft(event.target.value)}
            onKeyDown={(event) => {
              if (
                event.key === "Enter" &&
                !event.shiftKey &&
                !event.nativeEvent.isComposing
              ) {
                event.preventDefault()
                send()
              }
            }}
            rows={1}
            placeholder="Message Preet"
            aria-label="Message"
            className="min-h-10"
          />
          <InputGroupAddon align="inline-end">
            <InputGroupButton
              type="submit"
              variant="default"
              size="icon-xs"
              aria-label="Send"
              disabled={!draft.trim()}
            >
              <IconArrowUp />
            </InputGroupButton>
          </InputGroupAddon>
        </InputGroup>
      </form>
    </div>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/message.json

Adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from "@/components/ui/message"
<MessageGroup>
  <Message align="end">
    <MessageContent>
      <Bubble>
        <BubbleContent>On my way.</BubbleContent>
      </Bubble>
      <MessageFooter>Read</MessageFooter>
    </MessageContent>
  </Message>
</MessageGroup>

A message lays out an avatar, a name, one or more <Bubble />s and a status line. Bubbles inherit the message's align, so you set it once.

MessageGroup
└── Message
    ├── MessageAvatar
    └── MessageContent
        ├── MessageHeader
        ├── Bubble
        └── MessageFooter

Alignment

align="end" puts the avatar and bubbles on the other side for your own messages.

PS
Start is for the people you talk to.
ME
End is for you. The bubble follows.
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageGroup,
} from "@/components/ui/message"

export function MessageAlignment() {
  return (
    <MessageGroup className="w-full max-w-md">
      <Message>
        <MessageAvatar>
          <Avatar>
            <AvatarImage src="https://github.com/preetsuthar17.png" alt="" />
            <AvatarFallback>PS</AvatarFallback>
          </Avatar>
        </MessageAvatar>
        <MessageContent>
          <Bubble variant="secondary" shape="tail">
            <BubbleContent>Start is for the people you talk to.</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message align="end">
        <MessageAvatar>
          <Avatar>
            <AvatarFallback>ME</AvatarFallback>
          </Avatar>
        </MessageAvatar>
        <MessageContent>
          <Bubble shape="tail">
            <BubbleContent>End is for you. The bubble follows.</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
    </MessageGroup>
  )
}

<MessageHeader /> holds the name and time, and <MessageFooter /> a single line of status. The avatar always lines up with the last bubble, with or without a footer.

PS
Preet Suthar9:41
Pushed the fix.
Can you check the preview?
Edited
Looks right. Merging.
Read 9:43
Sending the release notes…
Sending
import { IconChecks, IconClock } from "@tabler/icons-react"

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

export function MessageHeaderFooter() {
  return (
    <MessageGroup className="w-full max-w-md">
      <Message>
        <MessageAvatar>
          <Avatar>
            <AvatarImage src="https://github.com/preetsuthar17.png" alt="" />
            <AvatarFallback>PS</AvatarFallback>
          </Avatar>
        </MessageAvatar>
        <MessageContent>
          <MessageHeader>
            Preet Suthar
            <span className="font-normal">9:41</span>
          </MessageHeader>
          <BubbleGroup shape="tail">
            <Bubble variant="secondary">
              <BubbleContent>Pushed the fix.</BubbleContent>
            </Bubble>
            <Bubble variant="secondary">
              <BubbleContent>Can you check the preview?</BubbleContent>
            </Bubble>
          </BubbleGroup>
          <MessageFooter>Edited</MessageFooter>
        </MessageContent>
      </Message>
      <Message align="end">
        <MessageContent>
          <Bubble shape="tail">
            <BubbleContent>Looks right. Merging.</BubbleContent>
          </Bubble>
          <MessageFooter>
            <IconChecks />
            Read 9:43
          </MessageFooter>
        </MessageContent>
      </Message>
      <Message align="end">
        <MessageContent>
          <Bubble variant="muted" shape="tail">
            <BubbleContent>Sending the release notes…</BubbleContent>
          </Bubble>
          <MessageFooter>
            <IconClock />
            Sending
          </MessageFooter>
        </MessageContent>
      </Message>
    </MessageGroup>
  )
}

Assistant

A ghost bubble spans the full width for long answers, and an icon in <MessageAvatar /> becomes a round tile.

What does a concentric radius mean?
Assistant
An inner corner whose radius is the outer radius minus the gap between the two edges. Nested shapes then curve together instead of fighting each other at the corners.
import { IconSparkles } from "@tabler/icons-react"

import { Bubble, BubbleContent } from "@/components/ui/bubble"
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageGroup,
  MessageHeader,
} from "@/components/ui/message"

export function MessageAssistant() {
  return (
    <MessageGroup className="w-full max-w-md">
      <Message align="end">
        <MessageContent>
          <Bubble variant="secondary">
            <BubbleContent>What does a concentric radius mean?</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
      <Message>
        <MessageAvatar>
          <IconSparkles />
        </MessageAvatar>
        <MessageContent>
          <MessageHeader>Assistant</MessageHeader>
          <Bubble variant="ghost">
            <BubbleContent>
              An inner corner whose radius is the outer radius minus the gap
              between the two edges. Nested shapes then curve together instead
              of fighting each other at the corners.
            </BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
    </MessageGroup>
  )
}

Long content

Bubbles wrap long links, and header and footer text can truncate without pushing the avatar.

PS
Preet Suthar from the HextaUI design systems team
https://example.com/a/very/long/link/that/does/not/break/naturally/at/all
Delivered to every device signed in to this workspace
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageHeader,
} from "@/components/ui/message"

export function MessageLongContent() {
  return (
    <div className="w-72 max-w-full">
      <Message>
        <MessageAvatar>
          <Avatar>
            <AvatarImage src="https://github.com/preetsuthar17.png" alt="" />
            <AvatarFallback>PS</AvatarFallback>
          </Avatar>
        </MessageAvatar>
        <MessageContent>
          <MessageHeader>
            <span className="truncate">
              Preet Suthar from the HextaUI design systems team
            </span>
          </MessageHeader>
          <Bubble variant="secondary">
            <BubbleContent>
              https://example.com/a/very/long/link/that/does/not/break/naturally/at/all
            </BubbleContent>
          </Bubble>
          <MessageFooter>
            <span className="truncate">
              Delivered to every device signed in to this workspace
            </span>
          </MessageFooter>
        </MessageContent>
      </Message>
    </div>
  )
}

Right to left

Start and end swap sides, and new messages grow from the matching corner.

م
ميرا
هل وصل المكون الجديد؟
نعم، إنه على الصفحة الآن.
تمت القراءة
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import {
  Message,
  MessageAvatar,
  MessageContent,
  MessageFooter,
  MessageGroup,
  MessageHeader,
} from "@/components/ui/message"

export function MessageRtl() {
  return (
    <div dir="rtl" className="w-full max-w-md">
      <MessageGroup>
        <Message>
          <MessageAvatar>
            <Avatar>
              <AvatarImage src="https://github.com/preetsuthar17.png" alt="" />
              <AvatarFallback>م</AvatarFallback>
            </Avatar>
          </MessageAvatar>
          <MessageContent>
            <MessageHeader>ميرا</MessageHeader>
            <Bubble variant="secondary">
              <BubbleContent>هل وصل المكون الجديد؟</BubbleContent>
            </Bubble>
          </MessageContent>
        </Message>
        <Message align="end">
          <MessageContent>
            <Bubble>
              <BubbleContent>نعم، إنه على الصفحة الآن.</BubbleContent>
            </Bubble>
            <MessageFooter>تمت القراءة</MessageFooter>
          </MessageContent>
        </Message>
      </MessageGroup>
    </div>
  )
}
  • Messages are plain content. For a live chat, wrap the list in a role="log" element so screen readers read new messages as they arrive.
  • Put the sender's name in a header or in visually hidden text, since alignment alone doesn't say who wrote a message.
  • The entrance only plays for messages added after the list first renders. With reduced motion they fade in.

Every part renders a <div> and accepts render.

The thread. Messages added after its first render animate in.

AttributeDescription
data-slot="message-group"Target the thread in CSS.
PropTypeDefault
alignSide of the conversation. Bubbles inherit it.
"start" | "end""start"
animatedRise in when added to a MessageGroup after its first render.
booleantrue
render
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="message"Target messages in CSS.
data-alignThe side.
data-enteringPresent on messages that animate in.
AttributeDescription
data-slot="message-avatar"Lines up with the last bubble. A bare icon or image is sized to 32px.
AttributeDescription
data-slot="message-content"Stacks the header, bubbles and footer.
AttributeDescription
data-slot="message-header"Name and time above the bubbles.
data-slot="message-footer"One line of status below. Keep it to a single line so the avatar stays aligned.