HextaUI

Bubble

Chat message bubbles with variants, grouped corners, reactions and room for interactive content.

I checked the registry output and removed the stale route.
Nice, thanks!
Did the preview deploy pick it up?
Yes. The preview at build 1482 serves the new route, and the old one now returns a 404 as expected.
import {
  Bubble,
  BubbleContent,
  BubbleGroup,
  BubbleReactions,
} from "@/components/ui/bubble"

export function BubbleDemo() {
  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <Bubble variant="secondary">
        <BubbleContent>
          I checked the registry output and removed the stale route.
        </BubbleContent>
        <BubbleReactions role="img" aria-label="Reactions: thumbs up">
          <span>👍</span>
        </BubbleReactions>
      </Bubble>
      <BubbleGroup>
        <Bubble align="end">
          <BubbleContent>Nice, thanks!</BubbleContent>
        </Bubble>
        <Bubble align="end">
          <BubbleContent>Did the preview deploy pick it up?</BubbleContent>
        </Bubble>
      </BubbleGroup>
      <Bubble variant="ghost">
        <BubbleContent>
          Yes. The preview at build 1482 serves the new route, and the old one
          now returns a 404 as expected.
        </BubbleContent>
      </Bubble>
    </div>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/bubble.json

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

import {
  Bubble,
  BubbleContent,
  BubbleGroup,
  BubbleReactions,
} from "@/components/ui/bubble"
<BubbleGroup>
  <Bubble variant="secondary">
    <BubbleContent>Did the deploy land?</BubbleContent>
  </Bubble>
  <Bubble align="end">
    <BubbleContent>It did.</BubbleContent>
  </Bubble>
</BubbleGroup>
BubbleGroup
└── Bubble
    ├── BubbleContent
    └── BubbleReactions

Variants

Seven treatments, from a strong primary bubble to unframed ghost content for assistant replies. destructive tints the surface and colors icons inside it.

Pushed the fix, can you take a look?
Looks good. Merging after CI passes.
Jamie joined the conversation
Pinned: release notes are in the doc.
Here is the summary you asked for.
Ghost bubbles drop the frame so assistant replies can span the full row and read like a document.
Message failed to send. Tap to retry.
import { IconAlertCircle } from "@tabler/icons-react"

import { Bubble, BubbleContent } from "@/components/ui/bubble"

export function BubbleVariants() {
  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <Bubble>
        <BubbleContent>Pushed the fix, can you take a look?</BubbleContent>
      </Bubble>
      <Bubble variant="secondary">
        <BubbleContent>Looks good. Merging after CI passes.</BubbleContent>
      </Bubble>
      <Bubble variant="muted">
        <BubbleContent>Jamie joined the conversation</BubbleContent>
      </Bubble>
      <Bubble variant="tinted">
        <BubbleContent>Pinned: release notes are in the doc.</BubbleContent>
      </Bubble>
      <Bubble variant="outline">
        <BubbleContent>Here is the summary you asked for.</BubbleContent>
      </Bubble>
      <Bubble variant="ghost">
        <BubbleContent>
          Ghost bubbles drop the frame so assistant replies can span the full
          row and read like a document.
        </BubbleContent>
      </Bubble>
      <Bubble variant="destructive">
        <BubbleContent>
          <IconAlertCircle /> Message failed to send. Tap to retry.
        </BubbleContent>
      </Bubble>
    </div>
  )
}

Alignment

align="end" pushes a bubble to the end of the row for the current user’s messages. It follows the reading direction.

Aligned to the start.
Aligned to the end.
import { Bubble, BubbleContent } from "@/components/ui/bubble"

export function BubbleAlignment() {
  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <Bubble variant="secondary" align="start">
        <BubbleContent>Aligned to the start.</BubbleContent>
      </Bubble>
      <Bubble align="end">
        <BubbleContent>Aligned to the end.</BubbleContent>
      </Bubble>
    </div>
  )
}

Group

Wrap consecutive messages from one sender in <BubbleGroup />. Corners on the sender side tighten so the run reads as one block, and a lone bubble stays fully round.

Hey!
Did the deploy land?
Asking because the dashboard still shows the old build number and I want to confirm before I post the announcement.
It did.
Hard refresh 🙏
A single bubble in a group stays round.
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble"

export function BubbleGroupDemo() {
  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <BubbleGroup>
        <Bubble variant="secondary">
          <BubbleContent>Hey!</BubbleContent>
        </Bubble>
        <Bubble variant="secondary">
          <BubbleContent>Did the deploy land?</BubbleContent>
        </Bubble>
        <Bubble variant="secondary">
          <BubbleContent>
            Asking because the dashboard still shows the old build number and I
            want to confirm before I post the announcement.
          </BubbleContent>
        </Bubble>
      </BubbleGroup>
      <BubbleGroup>
        <Bubble align="end">
          <BubbleContent>It did.</BubbleContent>
        </Bubble>
        <Bubble align="end">
          <BubbleContent>Hard refresh 🙏</BubbleContent>
        </Bubble>
      </BubbleGroup>
      <BubbleGroup>
        <Bubble variant="outline">
          <BubbleContent>A single bubble in a group stays round.</BubbleContent>
        </Bubble>
      </BubbleGroup>
    </div>
  )
}

Shapes

Set shape on a group to style all of its bubbles, or on a single bubble. joined is the default.

uniform Every bubble fully round.

Hey!
Did the deploy land?
It did.
Hard refresh and you should see build 1482 on the dashboard.
Perfect 🙏

joined Grouped corners tighten on the sender side.

Hey!
Did the deploy land?
It did.
Hard refresh and you should see build 1482 on the dashboard.
Perfect 🙏

tail The last bubble of a group gets a tail.

Hey!
Did the deploy land?
It did.
Hard refresh and you should see build 1482 on the dashboard.
Perfect 🙏
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble"

const shapes = [
  { shape: "uniform", description: "Every bubble fully round." },
  {
    shape: "joined",
    description: "Grouped corners tighten on the sender side.",
  },
  { shape: "tail", description: "The last bubble of a group gets a tail." },
] as const

export function BubbleShapes() {
  return (
    <div className="flex w-full max-w-md flex-col gap-10">
      {shapes.map(({ shape, description }) => (
        <div key={shape} className="flex flex-col gap-3">
          <p className="text-xs text-muted-foreground">
            <span className="font-medium text-foreground">{shape}</span>{" "}
            {description}
          </p>
          <BubbleGroup shape={shape}>
            <Bubble variant="secondary">
              <BubbleContent>Hey!</BubbleContent>
            </Bubble>
            <Bubble variant="secondary">
              <BubbleContent>Did the deploy land?</BubbleContent>
            </Bubble>
          </BubbleGroup>
          <BubbleGroup shape={shape}>
            <Bubble align="end">
              <BubbleContent>It did.</BubbleContent>
            </Bubble>
            <Bubble align="end">
              <BubbleContent>
                Hard refresh and you should see build 1482 on the dashboard.
              </BubbleContent>
            </Bubble>
          </BubbleGroup>
          <Bubble shape={shape} variant="secondary">
            <BubbleContent>Perfect 🙏</BubbleContent>
          </Bubble>
        </div>
      ))}
    </div>
  )
}

Tail

shape="tail" draws a tail on the last bubble of a run, matching the bubble color. Outline and ghost bubbles skip it.

Pushed the fix, can you take a look?
Looks good. Merging after CI passes.
Jamie joined the conversation
Pinned: release notes are in the doc.
Outline bubbles never get a tail.
Message failed to send. Tap to retry.
Reactions sit on the top corner.
ok
Only the last bubble in a group
gets the tail.
import {
  Bubble,
  BubbleContent,
  BubbleGroup,
  BubbleReactions,
} from "@/components/ui/bubble"

export function BubbleTail() {
  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <Bubble shape="tail">
        <BubbleContent>Pushed the fix, can you take a look?</BubbleContent>
      </Bubble>
      <Bubble shape="tail" variant="secondary" align="end">
        <BubbleContent>Looks good. Merging after CI passes.</BubbleContent>
      </Bubble>
      <Bubble shape="tail" variant="muted">
        <BubbleContent>Jamie joined the conversation</BubbleContent>
      </Bubble>
      <Bubble shape="tail" variant="tinted" align="end">
        <BubbleContent>Pinned: release notes are in the doc.</BubbleContent>
      </Bubble>
      <Bubble shape="tail" variant="outline">
        <BubbleContent>Outline bubbles never get a tail.</BubbleContent>
      </Bubble>
      <Bubble shape="tail" variant="destructive" align="end">
        <BubbleContent>Message failed to send. Tap to retry.</BubbleContent>
      </Bubble>
      <Bubble shape="tail" align="end">
        <BubbleContent>Reactions sit on the top corner.</BubbleContent>
        <BubbleReactions side="top" align="start" role="img" aria-label="Heart">
          <span>❤️</span>
        </BubbleReactions>
      </Bubble>
      <Bubble shape="tail" variant="secondary">
        <BubbleContent>ok</BubbleContent>
      </Bubble>
      <BubbleGroup shape="tail">
        <Bubble variant="secondary">
          <BubbleContent>Only the last bubble in a group</BubbleContent>
        </Bubble>
        <Bubble variant="secondary">
          <BubbleContent>gets the tail.</BubbleContent>
        </Bubble>
      </BubbleGroup>
    </div>
  )
}

Reactions

<BubbleReactions /> overlaps the bubble edge and reserves its own space, so the next row needs no extra gap. Give a static row role="img" and a label, or use buttons for interactive reactions.

Shipped the new onboarding flow.
Reactions can anchor to the top.
ok
Interactive reactions use buttons.
"use client"

import * as React from "react"
import {
  IconFlame,
  IconHeart,
  IconMoodSmile,
  IconThumbUp,
} from "@tabler/icons-react"

import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"

function ReactionToggle({
  label,
  icon,
  initial = false,
}: {
  label: string
  icon: React.ReactNode
  initial?: boolean
}) {
  const [pressed, setPressed] = React.useState(initial)

  return (
    <Button
      aria-label={label}
      aria-pressed={pressed}
      variant={pressed ? "default" : "secondary"}
      size="icon-xs"
      onClick={() => setPressed(!pressed)}
    >
      {icon}
    </Button>
  )
}

export function BubbleReactionsDemo() {
  return (
    <div className="flex w-full max-w-md flex-col gap-6">
      <Bubble variant="secondary">
        <BubbleContent>Shipped the new onboarding flow.</BubbleContent>
        <BubbleReactions
          role="img"
          aria-label="Reactions: thumbs up, fire, and 8 more"
        >
          <span>👍</span>
          <span>🔥</span>
          <span>+8</span>
        </BubbleReactions>
      </Bubble>
      <Bubble align="end">
        <BubbleContent>Reactions can anchor to the top.</BubbleContent>
        <BubbleReactions side="top" align="start" role="img" aria-label="Heart">
          <span>❤️</span>
        </BubbleReactions>
      </Bubble>
      <Bubble variant="secondary">
        <BubbleContent>ok</BubbleContent>
        <BubbleReactions
          role="img"
          aria-label="Reactions: party, eyes, rocket, check"
        >
          <span>🎉</span>
          <span>👀</span>
          <span>🚀</span>
          <span>✅</span>
        </BubbleReactions>
      </Bubble>
      <Bubble variant="outline">
        <BubbleContent>Interactive reactions use buttons.</BubbleContent>
        <BubbleReactions align="start">
          <ReactionToggle label="Thumbs up" icon={<IconThumbUp />} initial />
          <ReactionToggle label="Heart" icon={<IconHeart />} />
          <ReactionToggle label="Fire" icon={<IconFlame />} />
          <ReactionToggle label="Add reaction" icon={<IconMoodSmile />} />
        </BubbleReactions>
      </Bubble>
    </div>
  )
}

Show more

Render a <Collapsible /> as the bubble content to fold long messages. The bubble grows smoothly and the hidden text stays searchable.

Here is the full incident timeline. The first alert fired at 09:12 when p95 latency crossed 800ms on the checkout service.

import { IconChevronDown } from "@tabler/icons-react"

import { Bubble, BubbleContent } from "@/components/ui/bubble"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible"

export function BubbleShowMore() {
  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <Bubble variant="secondary">
        <Collapsible render={<BubbleContent />}>
          <p>
            Here is the full incident timeline. The first alert fired at 09:12
            when p95 latency crossed 800ms on the checkout service.
          </p>
          <CollapsibleContent>
            <p className="pt-2">
              At 09:20 we rolled back the connection pool change. Latency
              recovered by 09:24 and error rates returned to baseline by 09:31.
              A follow-up will add a canary stage for pool configuration.
            </p>
          </CollapsibleContent>
          <CollapsibleTrigger
            render={
              <button
                type="button"
                className="mt-1 inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium underline-offset-4 outline-none hover:underline focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden"
              />
            }
          >
            <span className="group-data-panel-open/collapsible-trigger:hidden">
              Show more
            </span>
            <span className="hidden group-data-panel-open/collapsible-trigger:inline">
              Show less
            </span>
            <IconChevronDown className="size-4 transition-transform duration-200 ease-out-cubic group-data-panel-open/collapsible-trigger:rotate-180 motion-reduce:transition-none" />
          </CollapsibleTrigger>
        </Collapsible>
      </Bubble>
    </div>
  )
}

Tooltip

Render a bubble as a tooltip trigger to show details like the read time. Add tabIndex so keyboard users can reach it.

Hover to see when this was read.
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip"

export function BubbleTooltip() {
  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <Tooltip>
        <TooltipTrigger render={<Bubble align="end" tabIndex={0} />}>
          <BubbleContent>Hover to see when this was read.</BubbleContent>
        </TooltipTrigger>
        <TooltipContent side="left" sideOffset={8}>
          Read 9:41 AM
        </TooltipContent>
      </Tooltip>
    </div>
  )
}

Popover

Render the content as a <button> to make the whole bubble a popover trigger, for example to explain a failed delivery.

import { IconAlertCircle } from "@tabler/icons-react"

import { Bubble, BubbleContent } from "@/components/ui/bubble"
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

export function BubblePopover() {
  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <Popover>
        <Bubble variant="destructive" align="end">
          <PopoverTrigger
            render={<BubbleContent render={<button type="button" />} />}
          >
            <IconAlertCircle /> Not delivered. Tap for details.
          </PopoverTrigger>
        </Bubble>
        <PopoverContent align="end" className="w-64">
          <PopoverHeader>
            <PopoverTitle>Delivery failed</PopoverTitle>
            <PopoverDescription>
              The recipient&apos;s server rejected the message (550: mailbox
              unavailable).
            </PopoverDescription>
          </PopoverHeader>
        </PopoverContent>
      </Popover>
    </div>
  )
}

Long content

Unbroken strings, URLs, emoji and CJK text wrap inside the bubble instead of overflowing a narrow column.

Supercalifragilisticexpialidocious_unbroken_string_that_never_ends_1234567890
https://example.com/a/very/long/path/that/keeps/going/and/going?with=query&params=true
👩‍👩‍👧‍👦👨🏽‍💻🏳️‍🌈 你好世界,这是一个很长的中文句子用于测试换行
k
import { Bubble, BubbleContent } from "@/components/ui/bubble"

export function BubbleLongContent() {
  return (
    <div className="flex w-72 max-w-full flex-col gap-3">
      <Bubble variant="secondary">
        <BubbleContent>
          Supercalifragilisticexpialidocious_unbroken_string_that_never_ends_1234567890
        </BubbleContent>
      </Bubble>
      <Bubble align="end">
        <BubbleContent>
          https://example.com/a/very/long/path/that/keeps/going/and/going?with=query&amp;params=true
        </BubbleContent>
      </Bubble>
      <Bubble variant="outline">
        <BubbleContent>
          👩‍👩‍👧‍👦👨🏽‍💻🏳️‍🌈 你好世界,这是一个很长的中文句子用于测试换行
        </BubbleContent>
      </Bubble>
      <Bubble variant="muted" align="end">
        <BubbleContent>k</BubbleContent>
      </Bubble>
      <Bubble variant="tinted">
        <BubbleContent> </BubbleContent>
      </Bubble>
    </div>
  )
}

Nested reply

A quoted bubble inside another keeps radii concentric with its parent.

Original: can we ship Friday?

Yes, Friday works.

import { Bubble, BubbleContent } from "@/components/ui/bubble"

export function BubbleNested() {
  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <Bubble align="end">
        <BubbleContent>
          <Bubble variant="tinted">
            <BubbleContent>Original: can we ship Friday?</BubbleContent>
          </Bubble>
          <p className="pt-2">Yes, Friday works.</p>
        </BubbleContent>
      </Bubble>
    </div>
  )
}

Right to left

Alignment, joined corners, tails and reactions all mirror in right-to-left layouts.

مرحبا! هل وصل التحديث؟
أنا أنتظر التأكيد.
نعم، تم النشر.
import {
  Bubble,
  BubbleContent,
  BubbleGroup,
  BubbleReactions,
} from "@/components/ui/bubble"

export function BubbleRtl() {
  return (
    <div dir="rtl" className="flex w-full max-w-md flex-col gap-3">
      <BubbleGroup>
        <Bubble variant="secondary">
          <BubbleContent>مرحبا! هل وصل التحديث؟</BubbleContent>
        </Bubble>
        <Bubble variant="secondary">
          <BubbleContent>أنا أنتظر التأكيد.</BubbleContent>
          <BubbleReactions role="img" aria-label="إعجاب">
            <span>👍</span>
            <span dir="ltr">+3</span>
          </BubbleReactions>
        </Bubble>
      </BubbleGroup>
      <BubbleGroup shape="tail">
        <Bubble align="end">
          <BubbleContent>نعم، تم النشر.</BubbleContent>
        </Bubble>
        <Bubble align="end">
          <BubbleContent render={<button type="button" />}>
            أرني السجل
          </BubbleContent>
        </Bubble>
      </BubbleGroup>
    </div>
  )
}
  • Bubbles are plain containers. Wrap a conversation in a list or a role="log" region if new messages should be announced.
  • Emoji-only reaction rows need role="img" and an aria-label that spells out the reactions.
  • Interactive content rendered as a button or link gets a focus ring that follows the bubble’s corners.

Every part renders a <div> by default and accepts its attributes, plus a render prop to swap the element.

PropTypeDefault
variant
"default" | "secondary" | "muted" | "tinted" | "outline" | "ghost" | "destructive""default"
align
"start" | "end""start"
shapeInherited from BubbleGroup when not set.
"uniform" | "joined" | "tail""joined"
render
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="bubble"Target bubbles in CSS.
data-variantThe current variant.
data-alignThe current alignment.
data-shapeThe resolved shape.
--bubble-bgThe surface color, also used by the tail.
--bubble-fgThe text color.
--bubble-borderThe ring color.
--bubble-radiusThe corner radius.
--bubble-radius-joinedThe tighter radius used between grouped bubbles.
--bubble-accentIcon color inside the content. Set by destructive.
PropTypeDefault
renderRender a <button> or <a> to make it interactive.
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="bubble-content"Target the bubble surface in CSS.
PropTypeDefault
shapeApplies to every bubble inside the group.
"uniform" | "joined" | "tail"–
render
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="bubble-group"Target groups in CSS.
data-shapeThe group shape, when set.
PropTypeDefault
side
"top" | "bottom""bottom"
align
"start" | "end""end"
render
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="bubble-reactions"Target reaction rows in CSS.
data-sideThe current side.
data-alignThe current alignment.