Inkbox

Component

Bubble

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

npx -y github:anwarlemu/inkbox-brand-cli#release add bubble
Preview
Hi, can you send me the invoice for August?
The one from acme@inkboxmail.com.
Sent it to you just now. It is also in your inbox as invoice-2026-08.pdf.
👍
Variants
@ops-agent: I can take the delegated task. Starting now.
Shared iMessage line connected.
Quoted: Your agent will be reachable at acme-support.inkboxwire.com
Not delivered. The recipient blocks unknown senders.
A ghost bubble sets text without a box, for long agent output.

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
  • An entrance animation on each bubble
  • Text smaller than 14px
  • Square corners

Exports: Bubble, BubbleContent, BubbleGroup, BubbleReactions, bubbleVariants. Read the examples for composition and props.

Dependencies: radix-ui, class-variance-authority.

Example

examples/bubble.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
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 };

Reference: Extrapolated from the Inkbox console (Figma 427:2715): iMessage and the inbox, in the action purple and the lilac.