3

Bubble

A chat message bubble with variants, reactions, and start/end alignment. Pair it with Message for a full chat row.

Last updated August 8, 2026

CreditsModifiedPublished

Copied from shadcn/ui.

  • Replaced physical left-3/right-3 with logical start-3/end-3 on BubbleReactions align variants
  • Replaced [button]:text-left with [button]:text-start on BubbleContent
  • Replaced bg-muted with bg-popover on BubbleReactions — this repo's --muted token is a 4% translucent overlay, which left the reaction pill nearly invisible

Overview

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.
import {
  Bubble,
  BubbleContent,
  BubbleGroup,
  BubbleReactions,
} from "@/components/ui/bubble"
 
export function BubbleDemoExample() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-8 py-4">
      <Bubble align="end">
        <BubbleContent>Hey there! what&apos;s up?</BubbleContent>
      </Bubble>
      <BubbleGroup>
        <Bubble variant="muted">
          <BubbleContent>Hey! Want to see chat bubbles?</BubbleContent>
        </Bubble>
        <Bubble variant="muted">
          <BubbleContent>
            I can group messages, switch sides, and keep the whole thread
            easy to scan.
          </BubbleContent>
          <BubbleReactions role="img" aria-label="Reaction: thumbs up">
            <span>👍</span>
          </BubbleReactions>
        </Bubble>
      </BubbleGroup>
      <Bubble align="end">
        <BubbleContent>Sure. Hit me with your best demo.</BubbleContent>
      </Bubble>
      <Bubble variant="muted">
        <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>
  )
}

Installation

$ npx shadcn@latest add https://ui.persian-labs.ir/r/bubble.json

Usage

import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble"
 
export function Example() {
  return (
    <BubbleGroup>
      <Bubble align="start" variant="muted">
        <BubbleContent>Hey, do you have a minute?</BubbleContent>
      </Bubble>
      <Bubble align="end">
        <BubbleContent>Sure, what's up?</BubbleContent>
      </Bubble>
    </BubbleGroup>
  )
}

Variants

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 and other content that should not be framed. They are full width and can take the full width of the container.
import {
  Bubble,
  BubbleContent,
  BubbleReactions,
} from "@/components/ui/bubble"
 
export function BubbleVariantsExample() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-12 py-4">
      <Bubble>
        <BubbleContent>This is the default primary bubble.</BubbleContent>
      </Bubble>
      <Bubble variant="secondary" align="end">
        <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="Reaction: thumbs up">
          <span>👍</span>
        </BubbleReactions>
      </Bubble>
      <Bubble variant="tinted" align="end">
        <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" align="end">
        <BubbleContent>Or a destructive variant with a reaction.</BubbleContent>
        <BubbleReactions role="img" aria-label="Reaction: fire">
          <span>🔥</span>
        </BubbleReactions>
      </Bubble>
      <Bubble variant="ghost">
        <BubbleContent>
          Ghost bubbles work for assistant text and other content that
          should not be framed. They are full width and can take the full
          width of the container.
        </BubbleContent>
      </Bubble>
    </div>
  )
}

Alignment

Use align on Bubble to align the bubble to the start or end of the conversation.

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

Bubble Group

Use BubbleGroup to group consecutive bubbles from the same 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.
import {
  Bubble,
  BubbleContent,
  BubbleGroup,
  BubbleReactions,
} from "@/components/ui/bubble"
 
export function BubbleGroupExample() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-8 py-4">
      <Bubble variant="muted">
        <BubbleContent>Can you tell me what&apos;s the issue?</BubbleContent>
      </Bubble>
      <BubbleGroup>
        <Bubble align="end">
          <BubbleContent>You tell me!</BubbleContent>
        </Bubble>
        <Bubble align="end">
          <BubbleContent>It worked yesterday. You broke it!</BubbleContent>
        </Bubble>
        <Bubble align="end">
          <BubbleContent>Find the bug and fix it.</BubbleContent>
          <BubbleReactions aria-label="Reactions: eyes" align="start">
            <span>👀</span>
          </BubbleReactions>
        </Bubble>
      </BubbleGroup>
      <Bubble variant="muted">
        <BubbleContent>
          Want me to diff yesterday&apos;s you against today&apos;s you?
          It&apos;s a bit embarrassing.
        </BubbleContent>
      </Bubble>
    </div>
  )
}

Turn a bubble into a link or button with the render prop on BubbleContent.

How can I help you today?
"use client"
 
import * as React from "react"
 
import {
  Bubble,
  BubbleContent,
  BubbleGroup,
} from "@/components/ui/bubble"
 
export function BubbleLinkButtonExample() {
  const [clicked, setClicked] = React.useState<string | null>(null)
 
  return (
    <div className="flex w-full max-w-sm flex-col gap-4 py-4">
      <Bubble variant="muted">
        <BubbleContent>How can I help you today?</BubbleContent>
      </Bubble>
      <BubbleGroup>
        <Bubble variant="tinted" align="end">
          <BubbleContent
            render={<button onClick={() => setClicked("forgot password")} />}
          >
            I forgot my password
          </BubbleContent>
        </Bubble>
        <Bubble variant="tinted" align="end">
          <BubbleContent
            render={
              <button onClick={() => setClicked("help with subscription")} />
            }
          >
            I need help with my subscription
          </BubbleContent>
        </Bubble>
        <Bubble variant="tinted" align="end">
          <BubbleContent
            render={<button onClick={() => setClicked("talk to a human")} />}
          >
            Something else. Talk to a human.
          </BubbleContent>
        </Bubble>
      </BubbleGroup>
      {clicked && (
        <p className="text-sm text-muted-foreground">
          You clicked &quot;{clicked}&quot;
        </p>
      )}
    </div>
  )
}

Reactions

Use BubbleReactions for reactions or quick action buttons. Use side and align to position the row.

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?
"use client"
 
import * as React from "react"
 
import {
  Bubble,
  BubbleContent,
  BubbleReactions,
} from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
 
export function BubbleReactionsExample() {
  const [ran, setRan] = React.useState(false)
 
  return (
    <div className="flex w-full max-w-sm flex-col gap-12 py-4">
      <Bubble variant="muted" align="end">
        <BubbleContent>
          I don&apos;t need tests, I know my code works.
        </BubbleContent>
        <BubbleReactions
          align="start"
          role="img"
          aria-label="Reactions: thumbs up, surprised"
        >
          <span>👍</span>
          <span>😮</span>
        </BubbleReactions>
      </Bubble>
      <Bubble variant="muted">
        <BubbleContent>
          Bold. Fine I&apos;ll add some tests. I&apos;ll let you know when
          they&apos;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="default" align="end">
        <BubbleContent>
          Tests passed on the first try. All 142 of them. Looking good!
        </BubbleContent>
        <BubbleReactions
          side="top"
          align="start"
          role="img"
          aria-label="Reactions: party popper, clapping hands"
        >
          <span>🎉</span>
          <span>👏</span>
        </BubbleReactions>
      </Bubble>
      <Bubble variant="destructive">
        <BubbleContent>Are you sure I can run this command?</BubbleContent>
        <BubbleReactions>
          <Button variant="ghost" size="xs" onClick={() => setRan(true)}>
            {ran ? "Running..." : "Yes, run it"}
          </Button>
        </BubbleReactions>
      </Bubble>
    </div>
  )
}

Show More / Collapsible

Long bubble content can be composed with Collapsible for a show more / show less interaction.

How can I help you today?

The accessibility review found two focus states that were visually too subtle in dark mode.

"use client"
 
import { ChevronDownIcon } from "lucide-react"
import * as React from "react"
 
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible"
 
const previewText =
  "The accessibility review found two focus states that were visually too subtle in dark mode."
 
const restText =
  " I checked the dialog, menu, and drawer paths because each one renders focusable controls inside a layered surface. The dialog and drawer are fine. The menu needs the hover and focus tokens split so keyboard focus stays visible when the pointer is not involved."
 
export function BubbleCollapsibleExample() {
  const [open, setOpen] = React.useState(false)
 
  return (
    <div className="flex w-full max-w-sm flex-col gap-8 py-4">
      <Bubble variant="muted">
        <BubbleContent>How can I help you today?</BubbleContent>
      </Bubble>
 
      <Bubble variant="muted" align="end">
        <BubbleContent>
          <Collapsible open={open} onOpenChange={setOpen}>
            <p>{previewText}</p>
            <CollapsibleContent className="mt-2">
              <p>{restText}</p>
            </CollapsibleContent>
            <CollapsibleTrigger
              render={
                <Button
                  variant="link"
                  className="mt-1 gap-1 p-0 text-muted-foreground"
                />
              }
            >
              {open ? "Show less" : "Show more"}
              <ChevronDownIcon />
            </CollapsibleTrigger>
          </Collapsible>
        </BubbleContent>
      </Bubble>
    </div>
  )
}

Tooltip

Wrap a reaction in Tooltip to reveal metadata on hover, such as a read receipt.

Did you remove the stale route?
Yes, removed it from the registry.
import { CheckIcon } from "lucide-react"
 
import {
  Bubble,
  BubbleContent,
  BubbleReactions,
} from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip"
 
export function BubbleTooltipExample() {
  return (
    <TooltipProvider>
      <div className="flex w-full max-w-sm flex-col gap-4 py-4">
        <Bubble variant="secondary">
          <BubbleContent>Did you remove the stale route?</BubbleContent>
        </Bubble>
        <Bubble align="end">
          <BubbleContent>Yes, removed it from the registry.</BubbleContent>
          <BubbleReactions>
            <Tooltip>
              <TooltipTrigger
                render={
                  <Button variant="ghost" size="icon-xs">
                    <CheckIcon />
                  </Button>
                }
              />
              <TooltipContent>Read on Jan 5, 2026 at 4:32 PM</TooltipContent>
            </Tooltip>
          </BubbleReactions>
        </Bubble>
      </div>
    </TooltipProvider>
  )
}

Popover

Pair a bubble with a Popover to surface more information on demand, such as a full error message.

Run the build script.
Failed to run the command.
import { InfoIcon } from "lucide-react"
 
import {
  Bubble,
  BubbleContent,
  BubbleReactions,
} from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
 
export function BubblePopoverExample() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-4 py-4">
      <Bubble align="end">
        <BubbleContent>Run the build script.</BubbleContent>
      </Bubble>
      <Bubble variant="destructive">
        <BubbleContent>Failed to run the command.</BubbleContent>
        <BubbleReactions>
          <Popover>
            <PopoverTrigger
              render={
                <Button
                  variant="ghost"
                  size="icon-xs"
                  aria-label="Show error details"
                  className="aria-expanded:text-destructive"
                >
                  <InfoIcon />
                </Button>
              }
            />
            <PopoverContent>
              <p className="text-sm font-medium">
                Command failed with exit code 1
              </p>
              <p className="mt-1 text-sm text-muted-foreground">
                ENOENT: no such file or directory, open pnpm-lock.yaml
              </p>
            </PopoverContent>
          </Popover>
        </BubbleReactions>
      </Bubble>
    </div>
  )
}

RTL

align="end" correctly sits on the visual left under RTL, and reactions mirror along with it, so a chat thread reads naturally either way.

سلام، یک دقیقه وقت داری؟
البته، چی شده؟
import {
  Bubble,
  BubbleContent,
  BubbleGroup,
  BubbleReactions,
} from "@/components/ui/bubble"
 
export function BubbleRtlExample() {
  return (
    <BubbleGroup className="w-full max-w-sm">
      <Bubble align="start" variant="muted">
        <BubbleContent>سلام، یک دقیقه وقت داری؟</BubbleContent>
      </Bubble>
      <Bubble align="end">
        <BubbleContent>البته، چی شده؟</BubbleContent>
        <BubbleReactions role="img" aria-label="واکنش: پسندیدن">
          <span>👍</span>
        </BubbleReactions>
      </Bubble>
    </BubbleGroup>
  )
}

API Reference

Bubble

BubbleReactions