Bubble

Message surfaces for chat, replies, suggestions, and reactions.

Frame chat text, structured output, quoted replies, suggestions, and reactions in a focused message surface.

Hey there! what's up?
Hey! Want to see chat bubbles?
I can group messages, switch sides, and keep the whole thread easy to scan.
Sure. Hit me with your best demo.
Yes. You are reading a demo that is demoing itself. Very meta. Very on-brand.
tsx
import {  Bubble,  BubbleContent,  BubbleReactions,} from "@/components/ui/bubble"export function Example() {  return (    <div className="flex w-full flex-col gap-3">      <Bubble variant="secondary">        <BubbleContent>Hey there! what's up?</BubbleContent>      </Bubble>      <Bubble variant="default" align="end">        <BubbleContent>Hey! Want to see chat bubbles?</BubbleContent>      </Bubble>      <Bubble variant="default" align="end">        <BubbleContent>          I can group messages, switch sides, and keep the whole thread easy to scan.        </BubbleContent>        <BubbleReactions role="img" aria-label="Reactions: thumbs up">          <span>👍</span>        </BubbleReactions>      </Bubble>      <Bubble variant="secondary">        <BubbleContent>Sure. Hit me with your best demo.</BubbleContent>      </Bubble>      <Bubble variant="default" align="end">        <BubbleContent>          Yes. You are reading a demo that is demoing itself. Very meta. Very on-brand.        </BubbleContent>        <BubbleReactions role="img" aria-label="Reactions: thumbs up, fire, eyes, and 2 more">          <span>👍</span>          <span>🔥</span>          <span>👀</span>          <span>+2</span>        </BubbleReactions>      </Bubble>    </div>  )}

Bubble handles the message surface itself. For avatars, names, timestamps, and row-level actions, use Message.

Installation

npx @arctis-sh/@arctis-sh/ui@latest add bubble

Usage

import {  Bubble,  BubbleContent,  BubbleGroup,  BubbleReactions,} from "@/components/ui/bubble"
<Bubble>  <BubbleContent>    I checked the registry output and removed the stale route.  </BubbleContent>  <BubbleReactions role="img" aria-label="Reactions: thumbs up">    <span>👍</span>  </BubbleReactions></Bubble>

Composition

Bubble
├── BubbleContent
└── BubbleReactions

BubbleGroup
├── Bubble
│   └── BubbleContent
└── Bubble
    └── BubbleContent

Basic

A short thread with bubbles on both sides and a few reactions.

Hey there! what's up?
Hey! Want to see chat bubbles?
I can group messages, switch sides, and keep the whole thread easy to scan.
Sure. Hit me with your best demo.
Yes. You are reading a demo that is demoing itself. Very meta. Very on-brand.
tsx
import {  Bubble,  BubbleContent,  BubbleReactions,} from "@/components/ui/bubble"export function Example() {  return (    <div className="flex w-full flex-col gap-3">      <Bubble variant="secondary">        <BubbleContent>Hey there! what's up?</BubbleContent>      </Bubble>      <Bubble variant="default" align="end">        <BubbleContent>Hey! Want to see chat bubbles?</BubbleContent>      </Bubble>      <Bubble variant="default" align="end">        <BubbleContent>          I can group messages, switch sides, and keep the whole thread easy to scan.        </BubbleContent>        <BubbleReactions role="img" aria-label="Reactions: thumbs up">          <span>👍</span>        </BubbleReactions>      </Bubble>      <Bubble variant="secondary">        <BubbleContent>Sure. Hit me with your best demo.</BubbleContent>      </Bubble>      <Bubble variant="default" align="end">        <BubbleContent>          Yes. You are reading a demo that is demoing itself. Very meta. Very on-brand.        </BubbleContent>        <BubbleReactions role="img" aria-label="Reactions: thumbs up, fire, eyes, and 2 more">          <span>👍</span>          <span>🔥</span>          <span>👀</span>          <span>+2</span>        </BubbleReactions>      </Bubble>    </div>  )}

Variants

Use variant to match the tone and emphasis of the content.

This is the default primary bubble.
This is the secondary variant.
This one is muted. It uses a lower emphasis color for the chat bubble.
This one is tinted. The tint is a softer color derived from the primary color.
We can also use an outlined variant.
Or a destructive variant with a reaction.
Ghost bubbles work for assistant text, markdown, and other content that should not be framed.
This is perfect for assistant messages that should not have a frame and can take the full width of the container.
tsx
import {  Bubble,  BubbleContent,  BubbleReactions,} from "@/components/ui/bubble"export function Example() {  return (    <div className="flex w-full flex-col gap-4">      <Bubble variant="default">        <BubbleContent>This is the default primary bubble.</BubbleContent>      </Bubble>      <Bubble variant="secondary">        <BubbleContent>This is the secondary variant.</BubbleContent>      </Bubble>      <Bubble variant="muted">        <BubbleContent>          This one is muted. It uses a lower emphasis color for the chat bubble.        </BubbleContent>        <BubbleReactions role="img" aria-label="Reactions: thumbs up">          <span>👍</span>        </BubbleReactions>      </Bubble>      <Bubble variant="tinted">        <BubbleContent>          This one is tinted. The tint is a softer color derived from the primary color.        </BubbleContent>      </Bubble>      <Bubble variant="outline">        <BubbleContent>We can also use an outlined variant.</BubbleContent>      </Bubble>      <Bubble variant="destructive">        <BubbleContent>Or a destructive variant with a reaction.</BubbleContent>        <BubbleReactions role="img" aria-label="Reactions: fire">          <span>🔥</span>        </BubbleReactions>      </Bubble>      <Bubble variant="ghost">        <BubbleContent>          Ghost bubbles work for assistant text, markdown, and other content that should not be framed.        </BubbleContent>      </Bubble>      <Bubble variant="ghost">        <BubbleContent>          This is perfect for assistant messages that should not have a frame and can take the full width of the container.        </BubbleContent>      </Bubble>    </div>  )}

| Variant | Description | | --- | --- | | default | Primary bubble, usually for the current user. | | secondary | Neutral conversation content. | | muted | Supporting content with less emphasis. | | tinted | A softer primary treatment. | | outline | Bordered secondary or rich content. | | ghost | Unframed assistant text or rich content. | | destructive | Errors or failed actions. |

A bubble sizes to its content, up to 80% of the container width. ghost removes the max-width so content can span the full row.

Alignment

Set align on Bubble to place it at the start or end of the row.

This bubble is aligned to the start. This is the default alignment.
This bubble is aligned to the end. Use this for user messages.
tsx
import { Bubble, BubbleContent } from "@/components/ui/bubble"export function Example() {  return (    <div className="flex w-full flex-col gap-3">      <Bubble variant="secondary" align="start">        <BubbleContent>          This bubble is aligned to the start. This is the default alignment.        </BubbleContent>      </Bubble>      <Bubble variant="default" align="end">        <BubbleContent>          This bubble is aligned to the end. Use this for user messages.        </BubbleContent>      </Bubble>    </div>  )}

| align | Description | | --- | --- | | start | Align the bubble to the start of the conversation. | | end | Align the bubble to the end of the conversation. |

For complete chat rows, set alignment on Message, not only on Bubble.

Bubble Group

Use BubbleGroup for consecutive messages from one sender. Set align on each Bubble, not on the group.

Can you tell me what's the issue?
You tell me!
It worked yesterday. You broke it!
Find the bug and fix it.
Want me to diff yesterday's you against today's you? It's a bit embarrassing.
tsx
import {  Bubble,  BubbleContent,  BubbleGroup,  BubbleReactions,} from "@/components/ui/bubble"export function Example() {  return (    <div className="flex w-full flex-col gap-4">      <BubbleGroup>        <Bubble variant="secondary">          <BubbleContent>Can you tell me what's the issue?</BubbleContent>        </Bubble>        <Bubble variant="secondary">          <BubbleContent>You tell me!</BubbleContent>        </Bubble>        <Bubble variant="secondary">          <BubbleContent>It worked yesterday. You broke it!</BubbleContent>        </Bubble>      </BubbleGroup>      <BubbleGroup>        <Bubble variant="default" align="end">          <BubbleContent>Find the bug and fix it.</BubbleContent>          <BubbleReactions role="img" aria-label="Reactions: eyes">            <span>👀</span>          </BubbleReactions>        </Bubble>        <Bubble variant="default" align="end">          <BubbleContent>            Want me to diff yesterday's you against today's you? It's a bit embarrassing.          </BubbleContent>        </Bubble>      </BubbleGroup>    </div>  )}
<BubbleGroup>  <Bubble variant="secondary">    <BubbleContent>What did the build report?</BubbleContent>  </Bubble>  <Bubble variant="secondary">    <BubbleContent>The registry check timed out.</BubbleContent>  </Bubble></BubbleGroup>

Pass render to BubbleContent when the whole surface should be a real <button> or <a>.

How can I help you today?
tsx
import { Bubble, BubbleContent } from "@/components/ui/bubble"export function Example() {  return (    <div className="flex w-full flex-col gap-3">      <Bubble variant="secondary">        <BubbleContent>How can I help you today?</BubbleContent>      </Bubble>      <Bubble variant="muted" align="end">        <BubbleContent render={<button type="button" />}>          I forgot my password        </BubbleContent>      </Bubble>      <Bubble variant="muted" align="end">        <BubbleContent render={<button type="button" />}>          I need help with my subscription        </BubbleContent>      </Bubble>      <Bubble variant="muted" align="end">        <BubbleContent render={<button type="button" />}>          Something else. Talk to a human.        </BubbleContent>      </Bubble>    </div>  )}
<Bubble variant="muted" align="end">  <BubbleContent render={<button type="button" />}>    I forgot my password  </BubbleContent></Bubble>

Reactions

BubbleReactions sits along the bubble edge. Use side and align to place it; the bubble reserves space so the next row keeps its usual gap.

I don't need tests, I know my code works.
Bold. Fine I'll add some tests. I'll let you know when they're done.
Tests passed on the first try. All 142 of them. Looking good!
Are you sure I can run this command?
tsx
import {  Bubble,  BubbleContent,  BubbleReactions,} from "@/components/ui/bubble"export function Example() {  return (    <div className="flex w-full flex-col gap-3">      <Bubble variant="secondary">        <BubbleContent>I don't need tests, I know my code works.</BubbleContent>        <BubbleReactions role="img" aria-label="Reactions: thumbs up, surprised">          <span>👍</span>          <span>😮</span>        </BubbleReactions>      </Bubble>      <Bubble variant="default" align="end">        <BubbleContent>          Bold. Fine I'll add some tests. I'll let you know when they're done.        </BubbleContent>        <BubbleReactions role="img" aria-label="Reactions: eyes, rocket, and 2 more">          <span>👀</span>          <span>🚀</span>          <span>+2</span>        </BubbleReactions>      </Bubble>      <Bubble variant="secondary">        <BubbleContent>          Tests passed on the first try. All 142 of them. Looking good!        </BubbleContent>        <BubbleReactions role="img" aria-label="Reactions: party, clap">          <span>🎉</span>          <span>👏</span>        </BubbleReactions>      </Bubble>      <Bubble variant="secondary">        <BubbleContent>Are you sure I can run this command?</BubbleContent>      </Bubble>      <Bubble variant="muted" align="end">        <BubbleContent render={<button type="button" />}>          Yes, run it        </BubbleContent>      </Bubble>    </div>  )}
<BubbleReactions role="img" aria-label="Reactions: thumbs up, fire">  <span>👍</span>  <span>🔥</span></BubbleReactions>

Show More / Collapsible

Use Collapsible inside a bubble when longer content should open on demand.

How can I help you today?
The accessibility review found two focus states that were visually too subtle in dark mode. I checked the dialog, menu, and drawer paths because each one renders focusable controls.
tsx
import { Bubble, BubbleContent } from "@/components/ui/bubble"import { buttonVariants } from "@/components/ui/button"import {  Collapsible,  CollapsibleContent,  CollapsibleTrigger,} from "@/components/ui/collapsible"import { cn } from "@/lib/utils"function ChevronDownIcon({ className }: { className?: string }) {  return (    <svg      viewBox="0 0 24 24"      fill="none"      stroke="currentColor"      strokeWidth="2"      strokeLinecap="round"      strokeLinejoin="round"      aria-hidden="true"      className={className}    >      <path d="m6 9 6 6 6-6" />    </svg>  )}export function Example() {  return (    <div className="flex w-full flex-col gap-3">      <Bubble variant="secondary">        <BubbleContent>How can I help you today?</BubbleContent>      </Bubble>      <Bubble variant="secondary">        <BubbleContent>          <Collapsible className="flex w-full flex-col gap-1">            <div>              The accessibility review found two focus states that were visually too subtle in dark mode. I checked the dialog, menu, and drawer paths because each one renders focusable controls.              <CollapsibleContent>                {" "}                The contrast dropped below AA on muted surfaces. Bumping the ring opacity and using a stronger outline token fixes both cases without changing the resting chrome.              </CollapsibleContent>            </div>            <CollapsibleTrigger              className={cn(                buttonVariants({ variant: "link" }),                "group/button-link gap-1",              )}            >              <span                data-slot="button-link-label"                className="relative inline after:absolute after:inset-x-0 after:bottom-0 after:h-px after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 after:ease-out group-hover/button-link:after:scale-x-100"              >                Show more              </span>              <ChevronDownIcon className="size-3.5 transition-transform duration-200 ease-out group-data-[state=open]/button-link:rotate-180" />            </CollapsibleTrigger>          </Collapsible>        </BubbleContent>      </Bubble>    </div>  )}
<Bubble variant="secondary">  <BubbleContent>    <Collapsible>      <div>        Short preview…        <CollapsibleContent> Full continuation.</CollapsibleContent>      </div>      <CollapsibleTrigger>Show more</CollapsibleTrigger>    </Collapsible>  </BubbleContent></Bubble>

Tooltip

Wrap a checkmark in Tooltip within BubbleReactions for read receipts or similar metadata.

Did you remove the stale route?
Yes, removed it from the registry.
tsx
import {  Bubble,  BubbleContent,  BubbleReactions,} from "@/components/ui/bubble"import {  Tooltip,  TooltipContent,  TooltipProvider,  TooltipTrigger,} from "@/components/ui/tooltip"function CheckIcon({ className }: { className?: string }) {  return (    <svg      viewBox="0 0 24 24"      fill="none"      stroke="currentColor"      strokeWidth="2"      strokeLinecap="round"      strokeLinejoin="round"      aria-hidden="true"      className={className}    >      <path d="M20 6 9 17l-5-5" />    </svg>  )}export function Example() {  return (    <TooltipProvider>      <div className="flex w-full flex-col gap-3">        <Bubble variant="secondary">          <BubbleContent>Did you remove the stale route?</BubbleContent>        </Bubble>        <Bubble variant="secondary">          <BubbleContent>Yes, removed it from the registry.</BubbleContent>          <BubbleReactions>            <Tooltip>              <TooltipTrigger asChild>                <button                  type="button"                  aria-label="Read receipt"                  className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground"                >                  <CheckIcon className="size-3" />                </button>              </TooltipTrigger>              <TooltipContent>Read 2 minutes ago</TooltipContent>            </Tooltip>          </BubbleReactions>        </Bubble>      </div>    </TooltipProvider>  )}
<BubbleReactions>  <Tooltip>    <TooltipTrigger asChild>      <button type="button" aria-label="Read receipt">        <CheckIcon />      </button>    </TooltipTrigger>    <TooltipContent>Read 2 minutes ago</TooltipContent>  </Tooltip></BubbleReactions>

Popover

Wrap an info control in Popover within BubbleReactions when the details need more room.

Run the build script.
Failed to run the command.
tsx
import {  Bubble,  BubbleContent,  BubbleReactions,} from "@/components/ui/bubble"import {  Popover,  PopoverContent,  PopoverDescription,  PopoverHeader,  PopoverTitle,  PopoverTrigger,} from "@/components/ui/popover"function InfoIcon({ className }: { className?: string }) {  return (    <svg      viewBox="0 0 24 24"      fill="none"      stroke="currentColor"      strokeWidth="2"      strokeLinecap="round"      strokeLinejoin="round"      aria-hidden="true"      className={className}    >      <circle cx="12" cy="12" r="10" />      <path d="M12 16v-4" />      <path d="M12 8h.01" />    </svg>  )}export function Example() {  return (    <div className="flex w-full flex-col gap-3">      <Bubble variant="secondary">        <BubbleContent>Run the build script.</BubbleContent>      </Bubble>      <Bubble variant="destructive" align="end">        <BubbleContent>Failed to run the command.</BubbleContent>        <BubbleReactions>          <Popover>            <PopoverTrigger asChild>              <button                type="button"                aria-label="Error details"                className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground aria-expanded:text-destructive"              >                <InfoIcon className="size-3" />              </button>            </PopoverTrigger>            <PopoverContent>              <PopoverHeader>                <PopoverTitle>Build failed</PopoverTitle>                <PopoverDescription>                  Exit code 1 — missing peer dependency for the registry builder.                </PopoverDescription>              </PopoverHeader>            </PopoverContent>          </Popover>        </BubbleReactions>      </Bubble>    </div>  )}
<BubbleReactions>  <Popover>    <PopoverTrigger asChild>      <button type="button" aria-label="Error details">        <InfoIcon />      </button>    </PopoverTrigger>    <PopoverContent></PopoverContent>  </Popover></BubbleReactions>

Accessibility

Bubble is presentational, so keep conversation semantics on the surrounding container.

Labeling reactions — group emoji as a single image with aria-label.

<BubbleReactions role="img" aria-label="Reactions: thumbs up, fire, and 8 more">  <span>👍</span>  <span>🔥</span>  <span>+8</span></BubbleReactions>

Use buttons with aria-label when reactions are interactive.

<BubbleReactions>  <Button variant="ghost" size="icon-xs" aria-label="Thumbs up">    <ThumbsUpIcon />  </Button></BubbleReactions>

Interactive bubbles — use render with a real <button> or <a>. The bubble text provides its accessible name.

<Bubble variant="muted" align="end">  <BubbleContent render={<button type="button" onClick={onReply} />}>    I forgot my password  </BubbleContent></Bubble>

Meaning beyond color — pair variants with text, alignment, or icons instead of relying on color alone.

API Reference

Bubble

The root wrapper. Standard HTML attributes pass through to it.

PropTypeDefault
variant"default" | "secondary" | "muted" | "tinted" | "outline" | "ghost" | "destructive""default"
align"start" | "end""start"
classNamestring
<Bubble variant="secondary" align="start">  <BubbleContent>Hello</BubbleContent></Bubble>

BubbleContent

Wraps the bubble content. Pass render to output another element, such as a link or button. It accepts native div props or props from the rendered element.

PropTypeDefault
renderReactElement
classNamestring
<BubbleContent>Hello</BubbleContent>
<BubbleContent render={<button type="button" />}>  Click here</BubbleContent>

BubbleReactions

Positions reactions along a bubble edge. Accepts native div props such as role and aria-label.

PropTypeDefault
side"top" | "bottom""bottom"
align"start" | "end""end"
classNamestring
<BubbleReactions role="img" aria-label="Reactions: thumbs up">  <span>👍</span></BubbleReactions>

BubbleGroup

Groups consecutive bubbles from the same sender.

PropTypeDefault
classNamestring
<BubbleGroup>  <Bubble variant="secondary">    <BubbleContent>First</BubbleContent>  </Bubble>  <Bubble variant="secondary">    <BubbleContent>Second</BubbleContent>  </Bubble></BubbleGroup>