Pho Design System

Bubble

One message’s surface: the rounded chip that carries the text, an image, or a line of prose, aligned to whichever side its author sits on. It knows nothing about who sent it or when — Message owns the row and Chat owns the thread.

Hey — is the number live yet?
Yes. Try sending to +1 (315) 997-8332.

Import

import { Bubble } from "@photon-ai/pho-ui/components/bubble";

Anatomy

Root is the bubble’s column and takes the variant and align. Content is the surface itself; Reactions hangs off a corner. Stack one author’s consecutive bubbles in a Group.

<Bubble.Root variant="inverse" align="end">
  <Bubble.Content>On my way.</Bubble.Content>
  <Bubble.Reactions>👍</Bubble.Reactions>
</Bubble.Root>

Variants

Monochrome, like every Pho surface. outlined (the default) is the card surface with the site hairline — a message received. inverse is the inverted chip, the site’s message-bubble token — a message sent. subtle is the quiet surface for a note or a quote; ghost drops the surface for prose from an assistant and runs full width; error is the send that did not go through.

outlined — Default — the card surface with the hairline; a message received
inverse — The inverted chip — a message sent
subtle — The quiet surface — a note, a system line, a quote
ghost — No surface — prose from an assistant, full width
error — Something failed — the send that did not go through

Align

start is the other side; end is you. The bubble caps at 80% of the row so the other side stays open, and its tail corner — a tighter sm radius — points at the author.

start — the other side
end — you

Interactive

Content renders through Base UI’s render, so a bubble can be the button that opens the message’s record, or a router link, without a wrapper. Only then does it take the hover wash and the focus ring. selected lifts the open one with a brand ring; pair it with aria-pressed so the state is announced.

<Bubble.Content
  render={
    <button type="button" aria-pressed={open} aria-label="Open message" />
  }
  selected={open}
  onClick={() => select(message.id)}
>
  {message.text}
</Bubble.Content>

Reactions

A small pill overlapping a corner — an emoji tapback, a count. It hangs from the bottom by default, off the corner away from the tail; side and align move it.

Shipping the inbox today.
🎉
Nice. Ping me when it's up.
👍 2

Group

Consecutive bubbles from one author sit in a Group: a 4px stack, tighter than the 12px between messages, so they read as one turn.

Two things.
The webhook signed. Delivery reports flow.
Both since 2:47 PM.
Perfect.

Props

Bubble.Root

Bubble.Content

Bubble.Reactions: side (top | bottom, default bottom), align (start | end, default: away from the tail).

BUBBLE_VARIANTS is exported for galleries and tests.

Best practices