---
title: Bubble
description: Message surfaces for chat, replies, suggestions, and reactions.
group: Components
order: 18
---

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

```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](/docs/components/message).

## Installation

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

## Usage

```tsx
import {
  Bubble,
  BubbleContent,
  BubbleGroup,
  BubbleReactions,
} from "@/components/ui/bubble"
```

```tsx
<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

```tree
Bubble
├── BubbleContent
└── BubbleReactions

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

## Basic

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

```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.

```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.

```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](/docs/components/message), not only on `Bubble`.

## Bubble Group

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

```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>
  )
}
```

```tsx
<BubbleGroup>
  <Bubble variant="secondary">
    <BubbleContent>What did the build report?</BubbleContent>
  </Bubble>
  <Bubble variant="secondary">
    <BubbleContent>The registry check timed out.</BubbleContent>
  </Bubble>
</BubbleGroup>
```

## Links and Buttons

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

```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>
  )
}
```

```tsx
<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.

```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>
  )
}
```

```tsx
<BubbleReactions role="img" aria-label="Reactions: thumbs up, fire">
  <span>👍</span>
  <span>🔥</span>
</BubbleReactions>
```

## Show More / Collapsible

Use [Collapsible](/docs/components/collapsible) inside a bubble when longer content should open on demand.

```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>
  )
}
```

```tsx
<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](/docs/components/tooltip) within `BubbleReactions` for read receipts or similar metadata.

```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>
  )
}
```

```tsx
<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](/docs/components/popover) within `BubbleReactions` when the details need more room.

```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>
  )
}
```

```tsx
<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`.

```tsx
<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.

```tsx
<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.

```tsx
<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.

| Prop | Type | Default |
| --- | --- | --- |
| variant | "default" \| "secondary" \| "muted" \| "tinted" \| "outline" \| "ghost" \| "destructive" | "default" |
| align | "start" \| "end" | "start" |
| className | string | — |

```tsx
<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.

| Prop | Type | Default |
| --- | --- | --- |
| render | ReactElement | — |
| className | string | — |

```tsx
<BubbleContent>Hello</BubbleContent>
```

```tsx
<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`.

| Prop | Type | Default |
| --- | --- | --- |
| side | "top" \| "bottom" | "bottom" |
| align | "start" \| "end" | "end" |
| className | string | — |

```tsx
<BubbleReactions role="img" aria-label="Reactions: thumbs up">
  <span>👍</span>
</BubbleReactions>
```

### BubbleGroup

Groups consecutive bubbles from the same sender.

| Prop | Type | Default |
| --- | --- | --- |
| className | string | — |

```tsx
<BubbleGroup>
  <Bubble variant="secondary">
    <BubbleContent>First</BubbleContent>
  </Bubble>
  <Bubble variant="secondary">
    <BubbleContent>Second</BubbleContent>
  </Bubble>
</BubbleGroup>
```

## Source

```tsx
import {
  cloneElement,
  isValidElement,
  type HTMLAttributes,
  type ReactElement,
  type ReactNode,
} from "react";
import { cn } from "@/lib/utils";

type BubbleVariant =
  | "default"
  | "secondary"
  | "muted"
  | "tinted"
  | "outline"
  | "ghost"
  | "destructive";

type BubbleAlign = "start" | "end";

const bubbleVariantClass: Record<BubbleVariant, string> = {
  default:
    "*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:opacity-85",
  secondary:
    "*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:opacity-85",
  muted:
    "*:data-[slot=bubble-content]:bg-muted *:data-[slot=bubble-content]:text-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent",
  tinted:
    "*:data-[slot=bubble-content]:bg-primary/10 *:data-[slot=bubble-content]:text-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/15",
  outline:
    "*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background *:data-[slot=bubble-content]:text-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted",
  ghost:
    "*:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 *:data-[slot=bubble-content]:text-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted",
  destructive:
    "*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/15",
};

type BubbleGroupProps = HTMLAttributes<HTMLDivElement> & {
  children?: ReactNode;
};

export function BubbleGroup({ className, ...props }: BubbleGroupProps) {
  return (
    <div
      data-slot="bubble-group"
      className={cn("flex min-w-0 flex-col gap-2", className)}
      {...props}
    />
  );
}

type BubbleProps = HTMLAttributes<HTMLDivElement> & {
  variant?: BubbleVariant;
  align?: BubbleAlign;
  children?: ReactNode;
};

export function Bubble({
  variant = "default",
  align = "start",
  className,
  ...props
}: BubbleProps) {
  return (
    <div
      data-slot="bubble"
      data-variant={variant}
      data-align={align}
      className={cn(
        "group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full has-[[data-slot=bubble-reactions]]:mb-4 has-[[data-slot=bubble-reactions][data-side=top]]:mt-4 has-[[data-slot=bubble-reactions][data-side=top]]:mb-0",
        bubbleVariantClass[variant],
        className,
      )}
      {...props}
    />
  );
}

type BubbleContentProps = HTMLAttributes<HTMLDivElement> & {
  render?: ReactElement;
  children?: ReactNode;
};

export function BubbleContent({
  className,
  render,
  children,
  ...props
}: BubbleContentProps) {
  const classes = cn(
    "w-fit max-w-full min-w-0 overflow-hidden rounded-md border border-transparent px-3 py-2 text-sm leading-relaxed tracking-wide break-words transition-colors duration-200 ease-out group-data-[align=end]/bubble:self-end [button]:text-left",
    className,
  );

  if (isValidElement(render)) {
    const element = render as ReactElement<{
      className?: string;
      children?: ReactNode;
    }>;

    return cloneElement(element, {
      ...element.props,
      ...props,
      "data-slot": "bubble-content",
      className: cn(classes, element.props.className),
      children: children ?? element.props.children,
    } as never);
  }

  return (
    <div data-slot="bubble-content" className={classes} {...props}>
      {children}
    </div>
  );
}

type BubbleReactionsProps = HTMLAttributes<HTMLDivElement> & {
  side?: "top" | "bottom";
  align?: BubbleAlign;
  children?: ReactNode;
};

export function BubbleReactions({
  side = "bottom",
  align = "end",
  className,
  ...props
}: BubbleReactionsProps) {
  return (
    <div
      data-slot="bubble-reactions"
      data-align={align}
      data-side={side}
      className={cn(
        "absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-xs tracking-wide ring-2 ring-background has-[button]:p-0",
        side === "top" && "top-0 -translate-y-3/4",
        side === "bottom" && "bottom-0 translate-y-3/4",
        align === "start" && "left-3",
        align === "end" && "right-3",
        className,
      )}
      {...props}
    />
  );
}

export type { BubbleVariant, BubbleAlign };
```
