# Bubble

Colored message surfaces for chat and conversation UIs, with optional reactions.

## Installation

```bash
npx shadcn@latest add https://reactdocs.canceydejean.dev/r/bubble.json
```

[Registry JSON](https://reactdocs.canceydejean.dev/r/bubble.json)

## Preview

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

export function Preview() {
  return (
    <BubbleGroup className="w-full max-w-md">
      <Bubble variant="muted" align="start">
        <BubbleContent>How can I help you today?</BubbleContent>
      </Bubble>
      <Bubble variant="default" align="end">
        <BubbleContent>Summarize the design review notes.</BubbleContent>
      </Bubble>
    </BubbleGroup>
  );
}
```


## Source

### bubble.tsx

```tsx
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { type VariantProps } from "class-variance-authority";
import * as React from "react";

import { cn } from "cn";

import { bubbleReactionsVariants, bubbleVariants } from "./bubble.variants";

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

function Bubble({
  variant = "default",
  align = "start",
  className,
  ...props
}: React.ComponentProps<"div"> &
  VariantProps<typeof bubbleVariants> & {
    align?: "start" | "end";
  }) {
  return (
    <div
      data-slot="bubble"
      data-variant={variant}
      data-align={align}
      className={cn(bubbleVariants({ variant }), className)}
      {...props}
    />
  );
}

function BubbleContent({
  className,
  render,
  ...props
}: useRender.ComponentProps<"div">) {
  return useRender({
    defaultTagName: "div",
    props: mergeProps<"div">(
      {
        className: cn(
          "w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50",
          className,
        ),
      },
      props,
    ),
    render,
    state: {
      slot: "bubble-content",
    },
  });
}

function BubbleReactions({
  side = "bottom",
  align = "end",
  className,
  ...props
}: React.ComponentProps<"div"> & {
  align?: "start" | "end";
  side?: "top" | "bottom";
}) {
  return (
    <div
      data-slot="bubble-reactions"
      data-align={align}
      data-side={side}
      className={cn(bubbleReactionsVariants({ side, align }), className)}
      {...props}
    />
  );
}

export { BubbleGroup, Bubble, BubbleContent, BubbleReactions };
```


### bubble.variants.ts

```ts
import { cva } from "class-variance-authority";

import { cn } from "cn"

const bubbleBase = 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",
);

const bubbleConfig = {
  variants: {
    variant: {
      default:
        "*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
      secondary:
        "*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
      muted:
        "*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
      tinted:
        "*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
      outline:
        "*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
      ghost:
        "border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
      destructive:
        "*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
    },
  },
  compoundVariants: [],
  defaultVariants: {
    variant: "default",
  },
} as const;

export const bubbleCva = cva(bubbleBase, {
  ...bubbleConfig,
  compoundVariants: [...bubbleConfig.compoundVariants],
});

// Export the variant variants
export const BUBBLE_VARIANTS = Object.keys(
  bubbleConfig.variants.variant,
) as (keyof typeof bubbleConfig.variants.variant)[];

export const bubbleVariants = bubbleCva;

const bubbleReactionsBase = cn(
  "absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
);

const bubbleReactionsConfig = {
  variants: {
    side: {
      top: "top-0 -translate-y-3/4",
      bottom: "bottom-0 translate-y-3/4",
    },
    align: {
      start: "left-3",
      end: "right-3",
    },
  },
  compoundVariants: [],
  defaultVariants: {
    side: "bottom",
    align: "end",
  },
} as const;

export const bubbleReactionsCva = cva(bubbleReactionsBase, {
  ...bubbleReactionsConfig,
  compoundVariants: [...bubbleReactionsConfig.compoundVariants],
});

// Export the side variants
export const BUBBLE_REACTION_SIDES = Object.keys(
  bubbleReactionsConfig.variants.side,
) as (keyof typeof bubbleReactionsConfig.variants.side)[];

// Export the align variants
export const BUBBLE_REACTION_ALIGNS = Object.keys(
  bubbleReactionsConfig.variants.align,
) as (keyof typeof bubbleReactionsConfig.variants.align)[];

export const bubbleReactionsVariants = bubbleReactionsCva;
```



## Usage

Use bubble for message text surfaces in chat threads. Pair with `Message` for full rows when you
install the message component.

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

export function Example() {
  return (
    <BubbleGroup>
      <Bubble variant="muted" align="start">
        <BubbleContent>How can I help you today?</BubbleContent>
      </Bubble>
      <Bubble variant="default" align="end">
        <BubbleContent>Summarize the design review notes.</BubbleContent>
        <BubbleReactions side="bottom" align="end">
          <span className="px-1.5 text-xs">👍 2</span>
        </BubbleReactions>
      </Bubble>
    </BubbleGroup>
  );
}
```

Variants: `default`, `secondary`, `muted`, `tinted`, `outline`, `ghost`, `destructive`. Align with
`align` (`start` | `end`). Position reactions via `BubbleReactions` `side` and `align`.

