Blode UI
GitHub

Bubble

A message surface for chat interfaces, with variants, alignment, reactions, and composable content.

Loading
Loading...
import { Bubble, BubbleContent, BubbleGroup, BubbleReactions } from "@/components/ui/bubble";
 
export function BubbleDemo() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-8 py-12">
      <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 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 aria-label="Reactions: thumbs up, fire, eyes, and 2 more">
          <span>👍</span>
          <span>🔥</span>
          <span>👀</span>
          <span>+2</span>
        </BubbleReactions>
      </Bubble>
    </div>
  );
}

Bubble is the lower-level message surface. If you want a higher-level animated transcript entry that styles user and assistant turns for you, see Chat Message.

Installation

npx shadcn@latest add "https://ui.blode.co/r/styles/default/bubble"

Usage

import { Bubble, BubbleContent, BubbleGroup, BubbleReactions } from "@/components/ui/bubble";
<Bubble align="end">
  <BubbleContent>Hey there! what's up?</BubbleContent>
</Bubble>

Examples

Variants

Use variant to change the emphasis of a bubble. The ghost variant is unframed and full width, which works well for assistant text.

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

Alignment

Use align="end" for the current user's messages and align="start" (the default) for everyone else.

Loading
Loading...
import { Bubble, BubbleContent } from "@/components/ui/bubble";
 
export function BubbleAlignment() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-8 py-12">
      <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>
  );
}

Group

Wrap consecutive messages from the same sender in a BubbleGroup to tighten the spacing between them.

Loading
Loading...
import { Bubble, BubbleContent, BubbleGroup, BubbleReactions } from "@/components/ui/bubble";
 
export function BubbleGroupDemo() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-8 py-12">
      <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 align="start" aria-label="Reactions: eyes">
            <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>
  );
}

Reactions

Add a BubbleReactions element to overlay reactions on a bubble. Control the placement with side and align.

Loading
Loading...
"use client";
 
import { toast } from "sonner";
 
import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
 
export function BubbleReactionsExample() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-12 py-12">
      <Bubble align="end" variant="muted">
        <BubbleContent>I don&apos;t need tests, I know my code works.</BubbleContent>
        <BubbleReactions align="start" 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 aria-label="Reactions: eyes, rocket, and 2 more">
          <span>👀</span>
          <span>🚀</span>
          <span>+2</span>
        </BubbleReactions>
      </Bubble>
      <Bubble align="end" variant="default">
        <BubbleContent>Tests passed on the first try. All 142 of them. Looking good!</BubbleContent>
        <BubbleReactions
          align="start"
          aria-label="Reactions: party popper, clapping hands"
          side="top"
        >
          <span>🎉</span>
          <span>👏</span>
        </BubbleReactions>
      </Bubble>
      <Bubble variant="destructive">
        <BubbleContent>Are you sure I can run this command?</BubbleContent>
        <BubbleReactions>
          <Button
            onClick={() => toast.success("You clicked yes, running command...")}
            size="xs"
            variant="ghost"
          >
            Yes, run it
          </Button>
        </BubbleReactions>
      </Bubble>
    </div>
  );
}

Tooltip

Reactions can hold interactive content, such as a tooltip trigger.

Loading
Loading...
import { CheckIcon } from "blode-icons-react";
 
import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
 
export function BubbleTooltip() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-4 py-12">
      <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 size="icon-xs" variant="ghost" />}>
              <CheckIcon />
            </TooltipTrigger>
            <TooltipContent>Read on Jan 5, 2026 at 4:32 PM</TooltipContent>
          </Tooltip>
        </BubbleReactions>
      </Bubble>
    </div>
  );
}

Popover

Use a popover inside a reaction to surface extra detail, like an error message.

Loading
Loading...
import { InfoIcon } from "blode-icons-react";
 
import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover";
 
export function BubblePopover() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-4 py-12">
      <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
                  aria-label="Show error details"
                  className="aria-expanded:text-destructive"
                  size="icon-xs"
                  variant="ghost"
                />
              }
            >
              <InfoIcon />
            </PopoverTrigger>
            <PopoverContent>
              <PopoverHeader>
                <PopoverTitle className="text-sm">Command failed with exit code 1</PopoverTitle>
                <PopoverDescription className="text-sm">
                  ENOENT: no such file or directory, open pnpm-lock.yaml
                </PopoverDescription>
              </PopoverHeader>
            </PopoverContent>
          </Popover>
        </BubbleReactions>
      </Bubble>
    </div>
  );
}

Collapsible

Collapse long messages behind a show more / show less toggle.

Loading
Loading...
"use client";
 
import { ChevronDownIcon } from "blode-icons-react";
import * as React from "react";
 
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleTrigger } from "@/components/ui/collapsible";
 
const text = `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 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.
 
I also recommend keeping the change in the style file instead of the primitive so the other themes can choose their own focus treatment later.`;
 
const previewLength = 180;
 
export function BubbleCollapsible() {
  const [open, setOpen] = React.useState(false);
  const isLong = text.length > previewLength;
  const preview = `${text.slice(0, previewLength)}...`;
 
  return (
    <div className="flex w-full max-w-sm flex-col gap-8 py-12">
      <Bubble variant="muted">
        <BubbleContent>How can I help you today?</BubbleContent>
      </Bubble>
 
      <Bubble align="end" variant="muted">
        <BubbleContent className="whitespace-pre-line">
          <Collapsible onOpenChange={setOpen} open={open}>
            <div>{open || !isLong ? text : preview}</div>
            {isLong ? (
              <CollapsibleTrigger
                render={<Button className="gap-1 p-0 text-muted-foreground" variant="link" />}
              >
                {open ? "Show less" : "Show more"}
                <ChevronDownIcon
                  className="group-data-[panel-open]/button:rotate-180"
                  data-icon="inline-end"
                />
              </CollapsibleTrigger>
            ) : null}
          </Collapsible>
        </BubbleContent>
      </Bubble>
    </div>
  );
}

Render a bubble's content as a button to build quick-reply prompts.

Loading
Loading...
"use client";
 
import { toast } from "sonner";
 
import { Bubble, BubbleContent, BubbleGroup } from "@/components/ui/bubble";
 
export function BubbleLinkButton() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-8 py-12">
      <Bubble variant="muted">
        <BubbleContent>How can I help you today?</BubbleContent>
      </Bubble>
      <BubbleGroup>
        <Bubble align="end" variant="tinted">
          <BubbleContent
            render={
              <button
                aria-label="I forgot my password"
                onClick={() => toast("You clicked forgot password")}
                type="button"
              />
            }
          >
            I forgot my password
          </BubbleContent>
        </Bubble>
        <Bubble align="end" variant="tinted">
          <BubbleContent
            render={
              <button
                aria-label="I need help with my subscription"
                onClick={() => toast("You clicked help with subscription")}
                type="button"
              />
            }
          >
            I need help with my subscription
          </BubbleContent>
        </Bubble>
        <Bubble align="end" variant="tinted">
          <BubbleContent
            render={
              <button
                aria-label="Something else. Talk to a human."
                onClick={() => toast("You clicked something else. Talk to a human.")}
                type="button"
              />
            }
          >
            Something else. Talk to a human.
          </BubbleContent>
        </Bubble>
      </BubbleGroup>
    </div>
  );
}

API Reference

Bubble is composed of BubbleGroup, Bubble, BubbleContent, and BubbleReactions.

  • Bubble accepts variant (default, secondary, muted, tinted, outline, ghost, destructive) and align (start, end).
  • BubbleContent accepts a render prop to change the underlying element (for example a button or a).
  • BubbleReactions accepts side (top, bottom) and align (start, end).