A chat message row with an avatar, name, bubbles and status, where new messages rise in from the sender's side.
"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.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react class-variance-authority cn - 3
Copy and paste the following code into your project.
components/ui/message.tsx "use client" import * as React from "react" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { cn } from "cn" import { BubbleAlignContext } from "@/components/ui/bubble" type MessageAlign = "start" | "end" const MessageGroupContext = React.createContext<{ ready: boolean } | null>(null) function dataAttributes(slot: string, extra?: Record<string, string>) { return { "data-slot": slot, ...extra } as Record<string, string> } function MessageGroup({ className, render, ...props }: useRender.ComponentProps<"div">) { const [ready, setReady] = React.useState(false) React.useEffect(() => { setReady(true) }, []) const context = React.useMemo(() => ({ ready }), [ready]) const element = useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: cn("flex min-w-0 flex-col gap-4", className) }, props, dataAttributes("message-group") ), }) return ( <MessageGroupContext.Provider value={context}> {element} </MessageGroupContext.Provider> ) } type MessageProps = useRender.ComponentProps<"div"> & { align?: MessageAlign animated?: boolean } function Message({ className, align = "start", animated = true, render, children, ...props }: MessageProps) { const group = React.useContext(MessageGroupContext) const [entering] = React.useState(() => Boolean(animated && group?.ready)) const element = useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: cn( "group/message relative flex w-full min-w-0 items-end gap-2 text-sm data-entering:origin-bottom-left data-[align=end]:flex-row-reverse data-[align=end]:data-entering:origin-bottom-right data-entering:motion-safe:animate-message-in motion-reduce:data-entering:animate-in motion-reduce:data-entering:fade-in-0 rtl:data-entering:origin-bottom-right rtl:data-[align=end]:data-entering:origin-bottom-left", className ), children: ( <BubbleAlignContext.Provider value={align}> {children} </BubbleAlignContext.Provider> ), }, props, dataAttributes("message", { "data-align": align, ...(entering ? { "data-entering": "" } : {}), }) ), }) return element } function MessageAvatar({ className, render, ...props }: useRender.ComponentProps<"div">) { return useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: cn( "flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full group-has-[[data-slot=message-footer]]/message:mb-[calc(var(--spacing)*4+var(--spacing)*1.5)] [&>img]:size-8 [&>img]:object-cover [&>svg]:size-8 [&>svg]:rounded-full [&>svg]:bg-muted [&>svg]:p-2 [&>svg]:text-foreground", className ), }, props, dataAttributes("message-avatar") ), }) } function MessageContent({ className, render, ...props }: useRender.ComponentProps<"div">) { return useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: cn( "flex w-full min-w-0 flex-col gap-1.5 wrap-break-word", className ), }, props, dataAttributes("message-content") ), }) } const metaClassName = "flex max-w-full min-w-0 items-center gap-1.5 px-3 text-xs font-medium text-muted-foreground group-has-[[data-variant=ghost]]/message:px-0 group-data-[align=start]/message:group-has-[[data-shape=tail]]/message:ps-[calc(var(--spacing)*4.5)] group-data-[align=end]/message:group-has-[[data-shape=tail]]/message:pe-[calc(var(--spacing)*4.5)] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5" function MessageHeader({ className, render, ...props }: useRender.ComponentProps<"div">) { return useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: cn( metaClassName, "min-h-4 group-data-[align=end]/message:justify-end", className ), }, props, dataAttributes("message-header") ), }) } function MessageFooter({ className, render, ...props }: useRender.ComponentProps<"div">) { return useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: cn( metaClassName, "h-4 overflow-hidden whitespace-nowrap group-data-[align=end]/message:justify-end", className ), }, props, dataAttributes("message-footer") ), }) } export { Message, MessageAvatar, MessageContent, MessageFooter, MessageGroup, MessageHeader, } export type { MessageAlign, MessageProps }components/ui/bubble.tsx "use client" import * as React from "react" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" type BubbleShape = "uniform" | "joined" | "tail" const BubbleShapeContext = React.createContext<BubbleShape | undefined>( undefined ) const BubbleAlignContext = React.createContext<"start" | "end" | undefined>( undefined ) function dataAttributes(slot: string, extra?: Record<string, string>) { return { "data-slot": slot, ...extra } as Record<string, string> } type BubbleGroupProps = useRender.ComponentProps<"div"> & { shape?: BubbleShape } function BubbleGroup({ className, shape, render, ...props }: BubbleGroupProps) { const element = useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: cn("flex min-w-0 flex-col gap-1", className) }, props, dataAttributes("bubble-group", shape ? { "data-shape": shape } : {}) ), }) return ( <BubbleShapeContext.Provider value={shape}> {element} </BubbleShapeContext.Provider> ) } const bubbleVariants = cva( "group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 [--bubble-bg:transparent] [--bubble-border:transparent] [--bubble-fg:currentColor] [--bubble-radius-bottom:var(--bubble-radius)] [--bubble-radius-joined:var(--radius-sm)] [--bubble-radius-top:var(--bubble-radius)] [--bubble-radius:var(--radius-xl)] [--bubble-tail:none] group-data-[align=end]/message:ms-auto in-data-[slot=bubble-content]:[--bubble-radius:max(calc(var(--radius-sm)*0.5),calc(var(--bubble-content-radius)-var(--spacing)*2))]", { variants: { variant: { default: "[--bubble-bg:var(--color-primary)] [--bubble-fg:var(--color-primary-foreground)]", secondary: "[--bubble-bg:var(--color-secondary)] [--bubble-fg:var(--color-secondary-foreground)]", muted: "[--bubble-bg:var(--color-muted)] [--bubble-fg:var(--color-foreground)]", tinted: "[--bubble-bg:oklch(from_var(--color-primary)_0.94_calc(c*0.4)_h)] [--bubble-fg:var(--color-foreground)] dark:[--bubble-bg:oklch(from_var(--color-primary)_0.3_calc(c*0.4)_h)]", outline: "[--bubble-bg:var(--color-background)] [--bubble-border:var(--color-border)] [--bubble-fg:var(--color-foreground)] dark:[--bubble-border:var(--color-input)]", ghost: "max-w-full [--bubble-radius:var(--radius-md)] *:data-[slot=bubble-content]:p-0", destructive: "[--bubble-accent:var(--color-destructive)] [--bubble-bg:color-mix(in_oklab,var(--color-destructive)_10%,var(--color-background))] [--bubble-fg:var(--color-foreground)] dark:[--bubble-bg:color-mix(in_oklab,var(--color-destructive)_18%,var(--color-background))]", }, align: { start: "[--bubble-tail-flip:-1] *:data-[slot=bubble-content]:rounded-ss-(--bubble-radius-top) *:data-[slot=bubble-content]:rounded-es-(--bubble-radius-bottom) rtl:[--bubble-tail-flip:1]", end: "ms-auto [--bubble-tail-flip:1] *:data-[slot=bubble-content]:self-end *:data-[slot=bubble-content]:rounded-se-(--bubble-radius-top) *:data-[slot=bubble-content]:rounded-ee-(--bubble-radius-bottom) rtl:[--bubble-tail-flip:-1]", }, shape: { uniform: "", joined: "", tail: "[--bubble-tail-cut:calc(var(--spacing)*2)] [--bubble-tail-height:calc(var(--spacing)*5)] [--bubble-tail-overlap:calc(var(--spacing)*2.5)] [--bubble-tail-width:calc(var(--spacing)*1.5)] [--bubble-tail:block] *:data-[slot=bubble-content]:relative *:data-[slot=bubble-content]:overflow-visible *:data-[slot=bubble-content]:after:pointer-events-none *:data-[slot=bubble-content]:after:absolute *:data-[slot=bubble-content]:after:bottom-0 *:data-[slot=bubble-content]:after:[display:var(--bubble-tail)] *:data-[slot=bubble-content]:after:h-(--bubble-tail-height) *:data-[slot=bubble-content]:after:w-[calc(var(--bubble-tail-width)+var(--bubble-tail-overlap))] *:data-[slot=bubble-content]:after:[scale:var(--bubble-tail-flip)_1] *:data-[slot=bubble-content]:after:rounded-bl-[calc(var(--spacing)*3.25)_calc(var(--spacing)*2.75)] *:data-[slot=bubble-content]:after:bg-(--bubble-bg) *:data-[slot=bubble-content]:after:[mask-image:linear-gradient(to_right,black_var(--bubble-tail-overlap),transparent_var(--bubble-tail-overlap)),radial-gradient(circle_var(--bubble-tail-cut)_at_100%_0,transparent_var(--bubble-tail-cut),black_calc(var(--bubble-tail-cut)+0.5px))] *:data-[slot=bubble-content]:after:[mask-size:100%_100%,var(--bubble-tail-cut)_var(--bubble-tail-cut)] *:data-[slot=bubble-content]:after:[mask-position:0_0,var(--bubble-tail-overlap)_100%] *:data-[slot=bubble-content]:after:[mask-repeat:no-repeat]", }, }, compoundVariants: [ { shape: "joined", align: "start", className: "[[data-slot=bubble-group]>&:has(+[data-slot=bubble][data-align=start])]:[--bubble-radius-bottom:var(--bubble-radius-joined)] [[data-slot=bubble-group]>[data-slot=bubble][data-align=start]+&]:[--bubble-radius-top:var(--bubble-radius-joined)]", }, { shape: "joined", align: "end", className: "[[data-slot=bubble-group]>&:has(+[data-slot=bubble][data-align=end])]:[--bubble-radius-bottom:var(--bubble-radius-joined)] [[data-slot=bubble-group]>[data-slot=bubble][data-align=end]+&]:[--bubble-radius-top:var(--bubble-radius-joined)]", }, { shape: "tail", align: "start", className: "ps-(--bubble-tail-width) *:data-[slot=bubble-content]:after:start-[calc(var(--bubble-tail-width)*-1)] [[data-slot=bubble-group]>&:has(+[data-slot=bubble][data-align=start])]:[--bubble-tail:none]", }, { shape: "tail", align: "end", className: "pe-(--bubble-tail-width) *:data-[slot=bubble-content]:after:end-[calc(var(--bubble-tail-width)*-1)] [[data-slot=bubble-group]>&:has(+[data-slot=bubble][data-align=end])]:[--bubble-tail:none]", }, { shape: "tail", variant: ["outline", "ghost"], className: "[--bubble-tail:none]", }, ], defaultVariants: { variant: "default", align: "start", shape: "joined", }, } ) type BubbleVariant = NonNullable<VariantProps<typeof bubbleVariants>["variant"]> type BubbleAlign = NonNullable<VariantProps<typeof bubbleVariants>["align"]> type BubbleProps = useRender.ComponentProps<"div"> & { variant?: BubbleVariant align?: BubbleAlign shape?: BubbleShape } function Bubble({ className, variant = "default", align: alignProp, shape: shapeProp, render, ...props }: BubbleProps) { const groupShape = React.useContext(BubbleShapeContext) const inheritedAlign = React.useContext(BubbleAlignContext) const align = alignProp ?? inheritedAlign ?? "start" const shape = shapeProp ?? groupShape ?? "joined" const element = useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: cn(bubbleVariants({ variant, align, shape }), className) }, props, dataAttributes("bubble", { "data-variant": variant, "data-align": align, "data-shape": shape, }) ), }) return ( <BubbleShapeContext.Provider value={undefined}> {element} </BubbleShapeContext.Provider> ) } function BubbleContent({ className, render, ...props }: useRender.ComponentProps<"div">) { return useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: cn( "w-fit max-w-full min-w-0 overflow-hidden rounded-(--bubble-radius) bg-(--bubble-bg) px-3 py-2 text-sm leading-relaxed wrap-anywhere text-(--bubble-fg) inset-ring-(length:--hairline) inset-ring-(--bubble-border) outline-none [--bubble-content-radius:var(--bubble-radius)] focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden forced-colors:border [&:is(button,a)]:text-start [&>svg]:inline-block [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:align-[-0.1875rem] [&>svg]:text-(--bubble-accent,currentColor)", className ), }, props, dataAttributes("bubble-content") ), }) } const bubbleReactionsVariants = cva( "relative z-10 flex w-max max-w-[calc(100%-var(--spacing)*3)] flex-wrap items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm leading-5 ring-3 ring-background select-none has-[>button]:p-0 *:data-[slot=button]:rounded-full", { variants: { side: { top: "order-first -mb-2.5", bottom: "-mt-2.5", }, align: { start: "ms-3 self-start", end: "me-3 self-end", }, }, defaultVariants: { side: "bottom", align: "end", }, } ) type BubbleReactionsProps = useRender.ComponentProps<"div"> & { side?: NonNullable<VariantProps<typeof bubbleReactionsVariants>["side"]> align?: NonNullable<VariantProps<typeof bubbleReactionsVariants>["align"]> } function BubbleReactions({ className, side = "bottom", align = "end", render, ...props }: BubbleReactionsProps) { return useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { className: cn(bubbleReactionsVariants({ side, align }), className), }, props, dataAttributes("bubble-reactions", { "data-side": side, "data-align": align, }) ), }) } export { Bubble, BubbleAlignContext, BubbleContent, BubbleGroup, BubbleReactions, bubbleReactionsVariants, bubbleVariants, } export type { BubbleAlign, BubbleGroupProps, BubbleProps, BubbleReactionsProps, BubbleShape, BubbleVariant, } - 4
Update the import paths to match your project setup.
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
└── MessageFooterAlignment#
align="end" puts the avatar and bubbles on the other side for your own messages.
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>
)
}Header and footer#
<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.
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.
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.
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.
| Attribute | Description |
|---|---|
data-slot="message-group" | Target the thread in CSS. |
| Prop | Type | Default |
|---|---|---|
alignSide of the conversation. Bubbles inherit it. | "start" | "end" | "start" |
animatedRise in when added to a MessageGroup after its first render. | boolean | true |
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="message" | Target messages in CSS. |
data-align | The side. |
data-entering | Present on messages that animate in. |
| Attribute | Description |
|---|---|
data-slot="message-avatar" | Lines up with the last bubble. A bare icon or image is sized to 32px. |
| Attribute | Description |
|---|---|
data-slot="message-content" | Stacks the header, bubbles and footer. |
| Attribute | Description |
|---|---|
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. |