preactcn
Chat / AI

bubble

$npx shadcn@latest add @preactcn/bubble

Sizes

Sizes
This is a one line bubble.
This bubble has multiple lines. It should wrap to the next line and you should see a different radius on the corners.

This bubble has multiple lines.

It should wrap to the next line and you should see a different radius on the corners.

Here is some more text to see how it wraps.

Variants

Variants
Default bubbles use the primary color for the active user side of a chat.
Secondary bubbles are the standard neutral surface for assistant and conversation content.
Muted bubbles lower the emphasis for quiet system notes or for displaying supporting content.
Tinted bubbles use a softer primary tint when primary fill is too strong.
Outline bubbles can be used to frame message content and give it a border.
Destructive bubbles flag errors or failed actions in a conversation.
Ghost bubbles work for assistant text 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. Ghost bubbles are full width and can take the full width of the container.

Alignment

Alignment
This bubble is aligned to the start.
This bubble is aligned to the end.
This multiline bubble is aligned to the start. The corners should adjust when the text wraps to show the grouped side of the conversation.
This multiline bubble is aligned to the end. It should sit on the opposite side with the matching corner radius for wrapped text.

Grouped

Grouped
I finished the audit pass.
The registry output looks clean, but I found one stale route.
Want me to remove it now?
Yes, clean that up.
Then rerun the registry build.

Collapsible

Collapsible
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...
Ghost bubbles work for assistant text 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. Use this for content that needs the whole row.

Reaction Placement

Reaction Placement
side=bottom align=end
This is a one line message.
A longer message that wraps across lines so the reaction offset is easier to inspect.
A longer message that wraps across lines so the reaction offset is easier to inspect.
side=bottom align=start
This is a one line message.
A longer message that wraps across lines so the reaction offset is easier to inspect.
side=top align=start
This is a one line message.
A longer message that wraps across lines so the reaction offset is easier to inspect.
side=bottom align=end
This is a one line message.
A longer message that wraps across lines so the reaction offset.

Reactions Buttons

Reactions Buttons
This is a one line message.
This is a one line message.
We are going to the movies first then dinner. Are you in?
Full example source
bubble-example.tsx
import { useState } from "preact/hooks"
import { toast } from "@/lib/shims/sonner"

import {
  Example,
  ExampleWrapper,
} from "@/components/example"
import {
  Bubble,
  BubbleContent,
  BubbleGroup,
  BubbleReactions,
} from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import {
  Collapsible,
  CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { Marker, MarkerContent } from "@/components/ui/marker"
import { ChevronDownIcon, ThumbsDownIcon, ThumbsUpIcon } from "lucide-preact"

export default function BubbleExample() {
  return (
    <ExampleWrapper>
      <BubbleSizes />
      <BubbleVariants />
      <BubbleAlignment />
      <BubbleGrouped />
      <BubbleCollapsible />
      <BubbleButtonLinks />
      <BubbleWithReactions />
      <BubbleReactionsButtons />
    </ExampleWrapper>
  )
}

function BubbleVariants() {
  return (
    <Example title="Variants">
      <div className="flex w-full max-w-md flex-col gap-8">
        <Bubble>
          <BubbleContent>
            Default bubbles use the primary color for the active user side of a
            chat.
          </BubbleContent>
        </Bubble>
        <Bubble variant="secondary">
          <BubbleContent>
            Secondary bubbles are the standard neutral surface for assistant and
            conversation content.
          </BubbleContent>
        </Bubble>
        <Bubble variant="muted">
          <BubbleContent>
            Muted bubbles lower the emphasis for quiet system notes or for
            displaying supporting content.
          </BubbleContent>
        </Bubble>
        <Bubble variant="tinted" align="end">
          <BubbleContent>
            Tinted bubbles use a softer primary tint when primary fill is too
            strong.
          </BubbleContent>
        </Bubble>
        <Bubble variant="outline">
          <BubbleContent>
            Outline bubbles can be used to frame message content and give it a
            border.
          </BubbleContent>
        </Bubble>
        <Bubble variant="destructive">
          <BubbleContent>
            Destructive bubbles flag errors or failed actions in a conversation.
          </BubbleContent>
        </Bubble>
        <Bubble variant="ghost">
          <BubbleContent>
            <span className="whitespace-pre-wrap">
              {`Ghost bubbles work for assistant text 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.

Ghost bubbles are full width and can take the full width of the container.
`}
            </span>
          </BubbleContent>
        </Bubble>
      </div>
    </Example>
  )
}

function BubbleSizes() {
  return (
    <Example title="Sizes">
      <div className="flex w-full max-w-md flex-col gap-8">
        <Bubble>
          <BubbleContent>This is a one line bubble.</BubbleContent>
        </Bubble>
        <Bubble>
          <BubbleContent>
            This bubble has multiple lines. It should wrap to the next line and
            you should see a different radius on the corners.
          </BubbleContent>
        </Bubble>
        <Bubble>
          <BubbleContent>
            <p>This bubble has multiple lines.</p>
            <p>
              It should wrap to the next line and you should see a different
              radius on the corners.
            </p>
            <p>Here is some more text to see how it wraps.</p>
          </BubbleContent>
        </Bubble>
      </div>
    </Example>
  )
}

function BubbleGrouped() {
  return (
    <Example title="Grouped">
      <div className="flex w-full max-w-md flex-col gap-8">
        <BubbleGroup>
          <Bubble variant="secondary">
            <BubbleContent>I finished the audit pass.</BubbleContent>
          </Bubble>
          <Bubble variant="secondary">
            <BubbleContent>
              The registry output looks clean, but I found one stale route.
            </BubbleContent>
          </Bubble>
          <Bubble variant="secondary">
            <BubbleContent>Want me to remove it now?</BubbleContent>
          </Bubble>
        </BubbleGroup>
        <BubbleGroup>
          <Bubble variant="tinted" align="end">
            <BubbleContent>Yes, clean that up.</BubbleContent>
          </Bubble>
          <Bubble variant="tinted" align="end">
            <BubbleContent>Then rerun the registry build.</BubbleContent>
          </Bubble>
        </BubbleGroup>
      </div>
    </Example>
  )
}

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

function BubbleCollapsible() {
  const [open, setOpen] = useState(false)
  const isLong = text.length > previewLength
  const preview = `${text.slice(0, previewLength)}...`

  return (
    <Example title="Collapsible">
      <div className="flex w-full max-w-md flex-col gap-8">
        <Collapsible open={open} onOpenChange={setOpen}>
          <Bubble variant="muted" align="end">
            <BubbleContent className="whitespace-pre-line">
              <div>{open || !isLong ? text : preview}</div>
              {isLong ? (
                <CollapsibleTrigger asChild>
                  <Button
                    variant="link"
                    className="gap-1 p-0 text-muted-foreground"
                  >
                    {open ? "Show less" : "Show more"}
                    <ChevronDownIcon
                      data-icon="inline-end"
                      className="group-data-open/button:rotate-180"
                    />
                  </Button>
                </CollapsibleTrigger>
              ) : null}
            </BubbleContent>
          </Bubble>
        </Collapsible>
        <Bubble variant="ghost">
          <BubbleContent>
            <span className="whitespace-pre-wrap">
              {`Ghost bubbles work for assistant text 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.

Use this for content that needs the whole row.`}
            </span>
          </BubbleContent>
        </Bubble>
      </div>
    </Example>
  )
}

function BubbleWithReactions() {
  return (
    <Example title="Reaction Placement">
      <div className="flex w-full max-w-md flex-col gap-12">
        <Marker variant="separator">
          <MarkerContent>side=bottom align=end</MarkerContent>
        </Marker>
        <Bubble>
          <BubbleContent>This is a one line message.</BubbleContent>
          <BubbleReactions
            side="bottom"
            align="end"
            role="img"
            aria-label="Reaction: thumbs up"
          >
            <span>👍</span>
          </BubbleReactions>
        </Bubble>
        <Bubble variant="secondary" align="end">
          <BubbleContent>
            A longer message that wraps across lines so the reaction offset is
            easier to inspect.
          </BubbleContent>
          <BubbleReactions
            side="bottom"
            align="start"
            role="img"
            aria-label="Reactions: thumbs up, surprised"
          >
            <span>👍</span>
            <span>😮</span>
          </BubbleReactions>
        </Bubble>
        <Bubble variant="tinted">
          <BubbleContent>
            A longer message that wraps across lines so the reaction offset is
            easier to inspect.
          </BubbleContent>
          <BubbleReactions
            side="bottom"
            align="end"
            role="img"
            aria-label="Reactions: thumbs up, surprised, fire, eyes, and 8 more"
          >
            <span>👍</span>
            <span>😮</span>
            <span>🔥</span>
            <span>👀</span>
            <span>+8</span>
          </BubbleReactions>
        </Bubble>
        <Marker variant="separator">
          <MarkerContent>side=bottom align=start</MarkerContent>
        </Marker>
        <Bubble variant="secondary">
          <BubbleContent>This is a one line message.</BubbleContent>
          <BubbleReactions
            side="bottom"
            align="start"
            role="img"
            aria-label="Reaction: fire"
          >
            <span>🔥</span>
          </BubbleReactions>
        </Bubble>
        <Bubble variant="secondary">
          <BubbleContent>
            A longer message that wraps across lines so the reaction offset is
            easier to inspect.
          </BubbleContent>
          <BubbleReactions
            side="bottom"
            align="start"
            role="img"
            aria-label="Reactions: thumbs up, surprised, fire, eyes"
          >
            <span>👍</span>
            <span>😮</span>
            <span>🔥</span>
            <span>👀</span>
          </BubbleReactions>
        </Bubble>
        <Marker variant="separator">
          <MarkerContent>side=top align=start</MarkerContent>
        </Marker>
        <Bubble variant="secondary">
          <BubbleContent>This is a one line message.</BubbleContent>
          <BubbleReactions
            side="top"
            align="start"
            role="img"
            aria-label="Reaction: fire"
          >
            <span>🔥</span>
          </BubbleReactions>
        </Bubble>
        <Bubble variant="secondary">
          <BubbleContent>
            A longer message that wraps across lines so the reaction offset is
            easier to inspect.
          </BubbleContent>
          <BubbleReactions
            side="top"
            align="start"
            role="img"
            aria-label="Reactions: thumbs up, surprised, fire, eyes"
          >
            <span>👍</span>
            <span>😮</span>
            <span>🔥</span>
            <span>👀</span>
          </BubbleReactions>
        </Bubble>
        <Marker variant="separator">
          <MarkerContent>side=bottom align=end</MarkerContent>
        </Marker>
        <Bubble variant="muted">
          <BubbleContent>This is a one line message.</BubbleContent>
          <BubbleReactions
            side="top"
            align="end"
            role="img"
            aria-label="Reaction: thumbs up"
          >
            <span>👍</span>
          </BubbleReactions>
        </Bubble>
        <Bubble variant="muted">
          <BubbleContent>
            A longer message that wraps across lines so the reaction offset.
          </BubbleContent>
          <BubbleReactions
            side="top"
            align="end"
            role="img"
            aria-label="Reactions: thumbs up, surprised, fire, eyes"
            className="px-1.5 py-0.5"
          >
            <span>👍</span>
            <span>😮</span>
            <span>🔥</span>
            <span>👀</span>
          </BubbleReactions>
        </Bubble>
      </div>
    </Example>
  )
}

function BubbleReactionsButtons() {
  return (
    <Example title="Reactions Buttons">
      <div className="flex w-full max-w-md flex-col gap-8">
        <Bubble>
          <BubbleContent>This is a one line message.</BubbleContent>
          <BubbleReactions>
            <Button
              variant="outline"
              size="xs"
              onClick={() =>
                toast("You clicked the button in the bubble reaction")
              }
            >
              Button
            </Button>
          </BubbleReactions>
        </Bubble>
        <Bubble align="end">
          <BubbleContent>This is a one line message.</BubbleContent>
          <BubbleReactions align="start">
            <Button
              variant="ghost"
              size="icon-xs"
              onClick={() => toast("Confetti!")}
            >
              🎉
            </Button>
          </BubbleReactions>
        </Bubble>
        <Bubble variant="tinted">
          <BubbleContent>
            We are going to the movies first then dinner. Are you in?
          </BubbleContent>
          <BubbleReactions className="gap-1 bg-background">
            <Button
              variant="secondary"
              size="icon-xs"
              aria-label="Thumbs up"
              onClick={() => toast("You agree!")}
            >
              <ThumbsUpIcon />
            </Button>
            <Button
              variant="secondary"
              size="icon-xs"
              aria-label="Thumbs down"
              onClick={() => toast("You disagree!")}
            >
              <ThumbsDownIcon />
            </Button>
          </BubbleReactions>
        </Bubble>
      </div>
    </Example>
  )
}

function BubbleAlignment() {
  return (
    <Example title="Alignment">
      <div className="flex w-full max-w-md flex-col gap-8">
        <Bubble variant="muted">
          <BubbleContent>This bubble is aligned to the start.</BubbleContent>
        </Bubble>
        <Bubble align="end">
          <BubbleContent>This bubble is aligned to the end.</BubbleContent>
        </Bubble>
        <Bubble variant="muted">
          <BubbleContent>
            This multiline bubble is aligned to the start. The corners should
            adjust when the text wraps to show the grouped side of the
            conversation.
          </BubbleContent>
        </Bubble>
        <Bubble align="end">
          <BubbleContent>
            This multiline bubble is aligned to the end. It should sit on the
            opposite side with the matching corner radius for wrapped text.
          </BubbleContent>
        </Bubble>
      </div>
    </Example>
  )
}

const quickReplies = [
  {
    label: "I need help with my account.",
    message: "I need help with my account.",
  },
  {
    label: "I forgot my password.",
    message: "I forgot my password.",
  },
  {
    label:
      "I have another question. I'd like to talk to a human. Can you help me?",
    message: "I have another question.",
  },
]

function BubbleButtonLinks() {
  return (
    <Example title="Button & Links">
      <div className="flex w-full max-w-md flex-col gap-8">
        <Bubble>
          <BubbleContent asChild>
            <a href="#">This bubble is a link.</a>
          </BubbleContent>
        </Bubble>
        <Bubble variant="secondary">
          <BubbleContent asChild>
            <button type="button">This one is a button you can click.</button>
          </BubbleContent>
        </Bubble>
        <Bubble variant="muted">
          <BubbleContent asChild>
            <button type="button">
              You can also do tinted buttons. Even ones that are multilines.
            </button>
          </BubbleContent>
        </Bubble>
        <Marker variant="separator">
          <MarkerContent>Chat Suggestions</MarkerContent>
        </Marker>
        <Bubble>
          <BubbleContent>How can I help you today?</BubbleContent>
        </Bubble>
        <BubbleGroup>
          {quickReplies.map((reply) => (
            <Bubble key={reply.label} variant="outline" align="end">
              <BubbleContent asChild className="border-dashed border-primary">
                <button type="button" onClick={() => toast(reply.message)}>
                  {reply.label}
                </button>
              </BubbleContent>
            </Bubble>
          ))}
        </BubbleGroup>
      </div>
    </Example>
  )
}