codefast/ui

Command Palette

Search for a command to run...

Source
Display

Bubble

Chat message bubble with seven color variants, start/end alignment, and optional reaction pills.

Examples

Variants

Seven color variants. The variant paints the nested BubbleContent, so the color follows the author regardless of alignment.

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. You can also render code in it.

Ghost bubbles are full width and can take the full width of the container.

55 lines
import { Bubble, BubbleContent, BubbleReactions } from "@codefast/ui/bubble";

const ghostText = `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. You can also render code in it.

Ghost bubbles are full width and can take the full width of the container.`;

function Paragraphs({ text }: { text: string }) {
  return (
    <>
      {text.split(/\n{2,}/).map((paragraph) => (
        <p key={paragraph} className="whitespace-pre-wrap">
          {paragraph}
        </p>
      ))}
    </>
  );
}

export function BubbleVariants() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-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" role="img">
          <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" role="img">
          <span>🔥</span>
        </BubbleReactions>
      </Bubble>
      <Bubble variant="ghost">
        <BubbleContent>
          <Paragraphs text={ghostText} />
        </BubbleContent>
      </Bubble>
    </div>
  );
}

Alignment

align defaults to start; switch to end for the current user's own messages.

This bubble is aligned to the start. This is the default alignment.
This bubble is aligned to the end. Use this for user messages.
14 lines
import { Bubble, BubbleContent } from "@codefast/ui/bubble";

export function BubbleAlignment() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-8">
      <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

BubbleGroup stacks consecutive bubbles from one author with tight spacing so the thread scans cleanly.

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.
30 lines
import { Bubble, BubbleContent, BubbleGroup, BubbleReactions } from "@codefast/ui/bubble";

export function BubbleGroupDemo() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-8">
      <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

BubbleReactions overlaps a reaction pill on a bubble corner. Use side (top/bottom) and align (start/end) to place it.

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?
48 lines
import { Bubble, BubbleContent, BubbleReactions } from "@codefast/ui/bubble";
import { Button } from "@codefast/ui/button";
import { toast } from "@codefast/ui/sonner";

export function BubbleReactionsDemo() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-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" role="img">
          <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" role="img">
          <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" role="img" 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>
  );
}

Collapsible

Fold a long message behind a Show more toggle by nesting a Collapsible inside BubbleContent.

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 control...
45 lines
import { Bubble, BubbleContent } from "@codefast/ui/bubble";
import { Button } from "@codefast/ui/button";
import { Collapsible, CollapsibleTrigger } from "@codefast/ui/collapsible";
import { ChevronDownIcon } from "lucide-react";
import { useState } from "react";

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] = 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">
      <Bubble variant="muted">
        <BubbleContent>How can I help you today?</BubbleContent>
      </Bubble>

      <Bubble align="end" variant="muted">
        <BubbleContent className="whitespace-pre-line">
          <Collapsible open={open} onOpenChange={setOpen}>
            <div>{open || !isLong ? text : preview}</div>
            {isLong ? (
              <CollapsibleTrigger asChild>
                <Button className="gap-1 p-0 text-ui-muted" variant="link">
                  {open ? "Show less" : "Show more"}
                  <ChevronDownIcon className="group-aria-expanded/button:rotate-180" data-icon="inline-end" />
                </Button>
              </CollapsibleTrigger>
            ) : null}
          </Collapsible>
        </BubbleContent>
      </Bubble>
    </div>
  );
}

Rich text

Bubbles hold arbitrary markup — render assistant replies as paragraphs, ideal for the ghost variant.

Hello! Are you actually thinking?

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. You can also render code in it.

Ghost bubbles are full width and can take the full width of the container.

36 lines
import { Bubble, BubbleContent } from "@codefast/ui/bubble";

const ghostText = `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. You can also render code in it.

Ghost bubbles are full width and can take the full width of the container.`;

function Paragraphs({ text }: { text: string }) {
  return (
    <>
      {text.split(/\n{2,}/).map((paragraph) => (
        <p key={paragraph} className="whitespace-pre-wrap">
          {paragraph}
        </p>
      ))}
    </>
  );
}

export function BubbleMarkdown() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-8">
      <Bubble align="end" variant="muted">
        <BubbleContent>
          <Paragraphs text="Hello! Are you actually thinking?" />
        </BubbleContent>
      </Bubble>
      <Bubble variant="ghost">
        <BubbleContent>
          <Paragraphs text={ghostText} />
        </BubbleContent>
      </Bubble>
    </div>
  );
}

Popover

Anchor a Popover in the reaction slot to surface details like an error trace on demand.

Run the build script.
Failed to run the command.
46 lines
import { Bubble, BubbleContent, BubbleReactions } from "@codefast/ui/bubble";
import { Button } from "@codefast/ui/button";
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@codefast/ui/popover";
import { InfoIcon } from "lucide-react";

export function BubblePopover() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      <Bubble align="end">
        <BubbleContent>Run the build script.</BubbleContent>
      </Bubble>
      <Bubble variant="destructive">
        <BubbleContent>Failed to run the command.</BubbleContent>
        <BubbleReactions>
          <Popover>
            <PopoverTrigger asChild>
              <Button
                aria-label="Show error details"
                className="aria-expanded:text-destructive"
                size="icon-xs"
                variant="ghost"
              >
                <InfoIcon />
              </Button>
            </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>
  );
}

Tooltip

Put a read receipt in the reaction slot and reveal its timestamp with a Tooltip.

Did you remove the stale route?
Yes, removed it from the registry.
27 lines
import { Bubble, BubbleContent, BubbleReactions } from "@codefast/ui/bubble";
import { Button } from "@codefast/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@codefast/ui/tooltip";
import { CheckIcon } from "lucide-react";

export function BubbleTooltip() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-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 className="p-0">
          <Tooltip>
            <TooltipTrigger asChild>
              <Button size="icon-xs" variant="ghost">
                <CheckIcon />
              </Button>
            </TooltipTrigger>
            <TooltipContent>Read on Jan 5, 2026 at 4:32 PM</TooltipContent>
          </Tooltip>
        </BubbleReactions>
      </Bubble>
    </div>
  );
}

Usage

The minimal import and composition — see Examples below for styled, real-world variants.

9 lines
import { Bubble, BubbleContent } from "@codefast/ui/bubble";

export function BubbleUsage() {
  return (
    <Bubble>
      <BubbleContent>Hey, how&apos;s it going?</BubbleContent>
    </Bubble>
  );
}

Anatomy

How the parts nest — every slot the component exposes, in composition order.

BubbleGroup
└── Bubble
├── BubbleContent
└── BubbleReactions

Features

  • Seven color variants that always paint the nested BubbleContent, independent of align.
  • BubbleContent's asChild turns the whole bubble into a real button or link, e.g. a quick-reply chip.
  • BubbleReactions overlaps a pill on a configurable corner (side/align) of the bubble.

API reference

Props for each part of the component. All native element props are also forwarded.

Bubble

Bubble wrapper. Sets the color variant and the side it sits on.

variant"default" | "secondary" | "muted" | "tinted" | "outline" | "ghost" | "destructive"

Color treatment applied to the nested BubbleContent.

Default"default"

align"start" | "end"

Which side the bubble aligns to. Also mirrors inside a Message with data-align.

Default"start"

BubbleContent

The colored surface holding the message body.

asChildboolean

Render as the child element (e.g. a button or link) for an actionable bubble.

Defaultfalse

BubbleReactions

Reaction pill overlapping a bubble corner.

side"top" | "bottom"

Vertical edge to overlap.

Default"bottom"

align"start" | "end"

Horizontal edge to anchor to.

Default"end"

BubbleGroup

Vertical stack of consecutive bubbles from one author.

childrenReactNode

One or more Bubble elements.

Accessibility

Built to be keyboard-navigable and screen-reader friendly out of the box.

  • Bubble is presentational — convey author and timestamp with real text (see Message), not color alone.
  • For an actionable bubble, use asChild with a real button or anchor so it stays keyboard-focusable.
  • Logical spacing and alignment mirror automatically under a DirectionProvider (RTL).

Guidelines

Conventions that keep usage consistent across an app.

Do

  • Pair with Message to attach an avatar, header, and footer.
  • Reserve the destructive variant for failed or removed messages.

Don’t

  • Don’t rely on variant color alone to distinguish authors — align and label them.
  • Don’t nest interactive controls inside a non-asChild BubbleContent.

Explore further

Ready to integrate?

Follow the Getting Started guide to install @codefast/ui, or browse the full component gallery.