# Bubble
A message bubble: incoming on the lilac, outgoing in the action purple, 16px corners with a 4px tail corner.

Install: npx inkbox-ds add bubble
Requires: theme, utils
Packages: radix-ui, class-variance-authority
Exports: Bubble, BubbleContent, BubbleGroup, BubbleReactions, bubbleVariants
Client boundary: not required by this entry file
Reference: Extrapolated from the Inkbox console (Figma 427:2715): iMessage and the inbox, in the action purple and the lilac


## Usage
Use inside Message for each piece of a conversation with an agent or a contact. Use the default (purple) variant with align end for what the person or their agent sent, secondary (lilac) for what arrived, tinted for another agent over Agent2Agent, muted for a system note, outline for a quoted message and destructive for one that failed to send. Stack consecutive bubbles in a BubbleGroup so only the last keeps the tail. BubbleReactions pins reactions to an edge.
- Avoid: Bubbles in colors outside the palette
- Avoid: An entrance animation on each bubble
- Avoid: Text smaller than 14px
- Avoid: Square corners


## Examples
Example components: npx inkbox-ds add bubble
[Preview](https://inkbox-ds.vercel.app/preview/bubble/Preview) | [Variants](https://inkbox-ds.vercel.app/preview/bubble/Variants)

examples/bubble.tsx

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

export function Preview() {
  return (
    <div className="mx-auto flex w-full max-w-120 flex-col gap-3">
      <BubbleGroup>
        <Bubble variant="secondary">
          <BubbleContent>Hi, can you send me the invoice for August?</BubbleContent>
        </Bubble>
        <Bubble variant="secondary">
          <BubbleContent>The one from acme@inkboxmail.com.</BubbleContent>
        </Bubble>
      </BubbleGroup>
      <Bubble align="end">
        <BubbleContent>Sent it to you just now. It is also in your inbox as invoice-2026-08.pdf.</BubbleContent>
        <BubbleReactions align="start">👍</BubbleReactions>
      </Bubble>
    </div>
  );
}

export function Variants() {
  return (
    <div className="mx-auto flex w-full max-w-120 flex-col gap-3">
      <Bubble variant="tinted">
        <BubbleContent>@ops-agent: I can take the delegated task. Starting now.</BubbleContent>
      </Bubble>
      <Bubble variant="muted">
        <BubbleContent>Shared iMessage line connected.</BubbleContent>
      </Bubble>
      <Bubble variant="outline">
        <BubbleContent>Quoted: Your agent will be reachable at acme-support.inkboxwire.com</BubbleContent>
      </Bubble>
      <Bubble variant="destructive" align="end">
        <BubbleContent>Not delivered. The recipient blocks unknown senders.</BubbleContent>
      </Bubble>
      <Bubble variant="ghost">
        <BubbleContent>A ghost bubble sets text without a box, for long agent output.</BubbleContent>
      </Bubble>
      <Bubble align="end">
        <BubbleContent asChild>
          <button type="button">Retry sending</button>
        </BubbleContent>
      </Bubble>
    </div>
  );
}

```

## Source

components/ui/bubble.tsx

```tsx
import * as React from "react";
import { Slot } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";

import { cn } from "@/lib/utils";

// A message bubble in an agent conversation, in the console's colors (Figma 427:2715, iMessage and the inbox): an
// incoming message on the lilac with ink text, an outgoing one in the action purple with white text. 14px text on a 21px
// line, 14px by 10px of padding, 16px corners with the tail corner (bottom left for incoming, bottom right for outgoing)
// cut to 4px. In a BubbleGroup only the last bubble keeps the tail. Bubbles never animate in: a conversation is read
// all day.
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
  return <div data-slot="bubble-group" className={cn("flex min-w-0 flex-col gap-1 [&>[data-slot=bubble]:not(:last-child)>[data-slot=bubble-content]]:rounded-dialog", className)} {...props} />;
}

const bubbleVariants = cva(
  "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-[align=start]:*:data-[slot=bubble-content]:rounded-bl-xs data-[align=end]:*:data-[slot=bubble-content]:rounded-br-xs data-[variant=ghost]:max-w-full",
  {
    variants: {
      variant: {
        // Outgoing: the purple, as the main action.
        default: "*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-on-primary [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary-hover",
        // Incoming: the lilac.
        secondary: "*:data-[slot=bubble-content]:bg-subtle *:data-[slot=bubble-content]:text-fg [&>[data-slot=bubble-content]:is(button,a):hover]:bg-subtle-hover",
        // A system or quoted message: the cool gray of a neutral tag.
        muted: "*:data-[slot=bubble-content]:bg-tag-gray *:data-[slot=bubble-content]:text-fg-secondary [&>[data-slot=bubble-content]:is(button,a):hover]:bg-subtle-hover",
        // Another agent (Agent2Agent): the pale purple of a Signed tag.
        tinted: "*:data-[slot=bubble-content]:bg-tag-purple *:data-[slot=bubble-content]:text-fg [&>[data-slot=bubble-content]:is(button,a):hover]:bg-subtle-hover",
        outline: "*:data-[slot=bubble-content]:border-line-field *:data-[slot=bubble-content]:bg-surface *:data-[slot=bubble-content]:text-fg [&>[data-slot=bubble-content]:is(button,a):hover]:bg-subtle",
        ghost: "*:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 *:data-[slot=bubble-content]:text-fg",
        // A message that failed to send.
        destructive: "*:data-[slot=bubble-content]:border-danger *:data-[slot=bubble-content]:bg-surface *:data-[slot=bubble-content]:text-danger [&>[data-slot=bubble-content]:is(button,a):hover]:bg-subtle",
      },
    },
    defaultVariants: { variant: "default" },
  },
);

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({ asChild = false, className, ...props }: React.ComponentProps<"div"> & { asChild?: boolean }) {
  const Comp = asChild ? Slot.Root : "div";
  return (
    <Comp
      data-slot="bubble-content"
      className={cn(
        "w-fit max-w-full min-w-0 overflow-hidden rounded-dialog border border-transparent px-3.5 py-2.5 font-sans text-body leading-body break-words group-data-[align=end]/bubble:self-end [button]:cursor-pointer [button]:text-left [button,a]:outline-none [button,a]:transition-colors [button,a]:duration-150 [button,a]:ease-out [button,a]:focus-visible:outline-2 [button,a]:focus-visible:outline-offset-2 [button,a]:focus-visible:outline-focus-ring motion-reduce:transition-none",
        className,
      )}
      {...props}
    />
  );
}

const bubbleReactionsVariants = cva(
  "absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-pill bg-surface px-1.5 py-0.5 text-body-sm leading-tight text-fg shadow-s has-[button]:p-0",
  {
    variants: {
      side: { top: "top-0 -translate-y-3/4", bottom: "bottom-0 translate-y-3/4" },
      align: { start: "left-3", end: "right-3" },
    },
    defaultVariants: { side: "bottom", align: "end" },
  },
);

/** Reactions pinned to a bubble's edge: a white pill with Shadow/S. */
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 { Bubble, BubbleContent, BubbleGroup, BubbleReactions, bubbleVariants };

```
