Bubble
Framed conversational surfaces using the same solid, subtle, surface, outline, ghost, and plain variants as Button and Badge, plus start/end alignment, grouping, and edge-anchored reactions.
12345678910111213141516171819202122232425262728293031import { Bubble } from "#components/ui/base-ui/bubble.tsx";import { indigoPalette } from "@stylexblocks/ui/themes/palettes.ts";
// Composition// Bubble.Root// ├── Bubble.Content// └── Bubble.Reactions//// Bubble.Group// ├── Bubble.Root → Bubble.Content// └── Bubble.Root → Bubble.Content
export function Example() { return ( // variant/align resolve once on Root; parts inherit them via context // and expose data-* attributes. Reactions take their own side/align. <Bubble.Root variant="solid" align="end" palette={indigoPalette}> <Bubble.Content> I checked the registry output and removed the stale route. </Bubble.Content> <Bubble.Reactions role="img" aria-label="Reactions: thumbs up"> <span>👍</span> </Bubble.Reactions> </Bubble.Root> );}
// Interactive content — replace the element with render:// <Bubble.Content render={<button type="button" />}>// I forgot my password// </Bubble.Content>Variants
Alignment
Use align on Bubble.Root to pin the surface to the start or end of the conversation. In a full chat row, prefer Message alignment instead.
12345678910111213141516171819import { Bubble } from "#components/ui/base-ui/bubble.tsx";import { indigoPalette } from "@stylexblocks/ui/themes/palettes.ts";
export function Example() { return ( <> <Bubble.Root variant="subtle" align="start"> <Bubble.Content> This bubble is aligned to the start. This is the default. </Bubble.Content> </Bubble.Root> <Bubble.Root variant="solid" align="end" palette={indigoPalette}> <Bubble.Content> This bubble is aligned to the end. Use this for user messages. </Bubble.Content> </Bubble.Root> </> );}Bubble Group
Bubble.Group stacks consecutive bubbles from the same sender. Set align on each Bubble.Root, not on the group.
1234567891011121314151617181920212223242526import { Bubble } from "#components/ui/base-ui/bubble.tsx";import { indigoPalette } from "@stylexblocks/ui/themes/palettes.ts";
export function Example() { return ( <> <Bubble.Root variant="subtle"> <Bubble.Content>Can you tell me what's the issue?</Bubble.Content> </Bubble.Root> <Bubble.Group> <Bubble.Root variant="solid" align="end" palette={indigoPalette}> <Bubble.Content>You tell me!</Bubble.Content> </Bubble.Root> <Bubble.Root variant="solid" align="end" palette={indigoPalette}> <Bubble.Content>It worked yesterday. You broke it!</Bubble.Content> </Bubble.Root> <Bubble.Root variant="solid" align="end" palette={indigoPalette}> <Bubble.Content>Find the bug and fix it.</Bubble.Content> <Bubble.Reactions role="img" aria-label="Reactions: eyes"> <span>👀</span> </Bubble.Reactions> </Bubble.Root> </Bubble.Group> </> );}Links and Buttons
Turn Bubble.Content into a real button or link with the render prop so the bubble is focusable and exposes the correct role.
123456789101112131415161718import { Bubble } from "#components/ui/base-ui/bubble.tsx";
export function Example() { return ( <Bubble.Group> <Bubble.Root variant="surface" align="end"> <Bubble.Content render={<button type="button" />}> I forgot my password </Bubble.Content> </Bubble.Root> <Bubble.Root variant="surface" align="end"> <Bubble.Content render={<button type="button" />}> I need help with my subscription </Bubble.Content> </Bubble.Root> </Bubble.Group> );}Reactions
Bubble.Reactions anchors a row to the bubble edge. Use side and align to place it — side="top" sits on the upper edge. Reactions overlap, so leave extra vertical space between rows.
123456789101112131415161718192021222324252627282930313233import { Bubble } from "#components/ui/base-ui/bubble.tsx";import { indigoPalette } from "@stylexblocks/ui/themes/palettes.ts";
export function Example() { return ( <> <Bubble.Root variant="solid" align="end" palette={indigoPalette}> <Bubble.Content>I don't need tests, I know my code works.</Bubble.Content> <Bubble.Reactions side="bottom" align="end" role="img" aria-label="Reactions: thumbs up, surprised" > <span>👍</span> <span>😮</span> </Bubble.Reactions> </Bubble.Root> <Bubble.Root variant="subtle"> <Bubble.Content>Tests passed on the first try.</Bubble.Content> <Bubble.Reactions side="top" align="end" role="img" aria-label="Reactions: party, clap" > <span>🎉</span> <span>👏</span> </Bubble.Reactions> </Bubble.Root> </> );}Show More / Collapsible
Compose long bubble copy with Collapsible so readers can expand the rest of the message without leaving the thread.
12345678910111213141516171819202122import { useState } from "react";import { Bubble } from "#components/ui/base-ui/bubble.tsx";import { Collapsible } from "#components/ui/base-ui/collapsible.tsx";
export function Example() { const [open, setOpen] = useState(false); return ( <Bubble.Root variant="subtle"> <Bubble.Content> <Collapsible.Root open={open} onOpenChange={setOpen}> <span>The accessibility review found two focus states that were too subtle.</span> <Collapsible.Panel> <span> The contrast now meets the target on every surface.</span> </Collapsible.Panel> <Collapsible.Trigger render={<button type="button" />}> {open ? "Show less" : "Show more"} </Collapsible.Trigger> </Collapsible.Root> </Bubble.Content> </Bubble.Root> );}Ghost and Plain
Ghost and plain drop the frame and the 80% max-width so assistant text and rich content can span the full row — the same unframed treatments as Button and Badge.
123456789101112131415161718import { Bubble } from "#components/ui/base-ui/bubble.tsx";
export function Example() { return ( <> <Bubble.Root variant="ghost"> <Bubble.Content> Ghost bubbles work for assistant text that should not be framed. </Bubble.Content> </Bubble.Root> <Bubble.Root variant="plain"> <Bubble.Content> Plain is the same unframed span without a hover fill. </Bubble.Content> </Bubble.Root> </> );}Tooltip
Wrap a bubble in Tooltip to reveal metadata on hover or focus, such as when a message was read.
12345678910111213141516171819import { Bubble } from "#components/ui/base-ui/bubble.tsx";import { Tooltip } from "#components/ui/base-ui/tooltip.tsx";
export function Example() { return ( <Tooltip.Root delay={200} closeDelay={100}> <Tooltip.Trigger render={<span />}> <Bubble.Root variant="subtle"> <Bubble.Content>Yes, removed it from the registry.</Bubble.Content> </Bubble.Root> </Tooltip.Trigger> <Tooltip.Portal> <Tooltip.Positioner> <Tooltip.Popup>Read · Yesterday 4:12 PM</Tooltip.Popup> </Tooltip.Positioner> </Tooltip.Portal> </Tooltip.Root> );}Popover
Pair a bubble with Popover to surface more information on demand, such as the full error for a failed action.
12345678910111213141516171819202122232425import { Bubble } from "#components/ui/base-ui/bubble.tsx";import { Popover } from "#components/ui/base-ui/popover.tsx";import { redPalette } from "@stylexblocks/ui/themes/palettes.ts";
export function Example() { return ( <Popover.Root> <Popover.Trigger render={<span />}> <Bubble.Root variant="subtle" palette={redPalette}> <Bubble.Content>Build failed</Bubble.Content> </Bubble.Root> </Popover.Trigger> <Popover.Portal> <Popover.Positioner> <Popover.Popup> <Popover.Title>Build failed</Popover.Title> <Popover.Description> `pnpm build` exited with code 1. </Popover.Description> </Popover.Popup> </Popover.Positioner> </Popover.Portal> </Popover.Root> );}Install
1npx stylexblocks add bubbleSource
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257import * as stylex from "@stylexjs/stylex";import { mergeProps } from "@base-ui-components/react/merge-props";import { useRender } from "@base-ui-components/react/use-render";import { createSlotRecipeContext } from "#lib/base-ui/create-slot-recipe-context.tsx";import { defineSlotRecipe, type WithStyleOverrides } from "#lib/recipe-props.ts";import { colorPalette } from '@stylexblocks/ui/color-palette.stylex.ts'import { fontSizes } from '@stylexblocks/ui/tokens/font-sizes.stylex.ts'import { lineHeights } from '@stylexblocks/ui/tokens/line-heights.stylex.ts'import { radii } from '@stylexblocks/ui/tokens/radii.stylex.ts'import { semanticColors } from '@stylexblocks/ui/semantic-tokens/colors.stylex.ts'import { semanticRadii } from '@stylexblocks/ui/semantic-tokens/radii.stylex.ts'import { spacing } from '@stylexblocks/ui/tokens/spacing.stylex.ts'import { zIndices } from '@stylexblocks/ui/tokens/z-indices.stylex.ts'
const bubbleRoot = stylex.create({ base: { display: 'flex', position: 'relative', flexDirection: 'column', gap: spacing._1, width: 'fit-content', maxWidth: '80%', minWidth: 0, },})
const bubbleRootAligns = stylex.create({ start: { alignSelf: 'flex-start', }, end: { alignSelf: 'flex-end', },})
const bubbleRootVariants = stylex.create({ ghost: { maxWidth: '100%', width: '100%', }, plain: { maxWidth: '100%', width: '100%', },})
const bubbleContent = stylex.create({ base: { minWidth: 0, overflowWrap: 'anywhere', borderRadius: semanticRadii.panel, paddingInline: spacing._3, paddingBlock: spacing._2, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, outlineWidth: { default: null, ':focus-visible': '2px', }, outlineOffset: { default: null, ':focus-visible': '2px', }, outlineStyle: { default: null, ':focus-visible': 'solid', }, outlineColor: { default: null, ':focus-visible': colorPalette.focusRing, }, cursor: { default: null, ':is(button, a)': 'pointer', }, },})
const bubbleContentVariants = stylex.create({ solid: { backgroundColor: { default: colorPalette.solid, ':is(button, a):hover': `color-mix(in oklch, ${colorPalette.solid} 80%, transparent)`, }, color: colorPalette.contrast, }, subtle: { backgroundColor: { default: colorPalette.subtle, ':is(button, a):hover': colorPalette.muted, }, color: colorPalette.fg, }, surface: { backgroundColor: { default: colorPalette.muted, ':is(button, a):hover': colorPalette.subtle, }, color: colorPalette.fg, boxShadow: 'inset 0 0 0px 1px var(--shadow-color)', '--shadow-color': colorPalette.border, }, outline: { color: colorPalette.fg, backgroundColor: { default: 'transparent', ':is(button, a):hover': colorPalette.muted, }, boxShadow: 'inset 0 0 0px 1px var(--shadow-color)', '--shadow-color': colorPalette.border, }, ghost: { backgroundColor: { default: 'transparent', ':is(button, a):hover': colorPalette.subtle, }, color: colorPalette.fg, paddingInline: 0, paddingBlock: 0, borderRadius: 0, }, plain: { color: colorPalette.fg, backgroundColor: 'transparent', paddingInline: 0, paddingBlock: 0, borderRadius: 0, },})
const bubbleReactions = stylex.create({ base: { position: 'absolute', zIndex: zIndices.docked, display: 'flex', width: 'fit-content', flexShrink: 0, alignItems: 'center', justifyContent: 'center', gap: spacing._1, borderRadius: radii.full, backgroundColor: semanticColors.bgMuted, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, boxShadow: `0 0 0 3px ${semanticColors.bgPanel}`, paddingInline: { default: spacing._1_5, ':has(button)': 0, }, paddingBlock: { default: spacing._0_5, ':has(button)': 0, }, },})
const bubbleReactionsSides = stylex.create({ top: { top: 0, transform: 'translateY(-75%)', }, bottom: { bottom: 0, transform: 'translateY(75%)', },})
const bubbleReactionsAligns = stylex.create({ start: { insetInlineStart: spacing._3, }, end: { insetInlineEnd: spacing._3, },})
const bubbleGroup = stylex.create({ base: { display: 'flex', flexDirection: 'column', gap: spacing._1, minWidth: 0, width: '100%', },})
export type BubbleVariant = keyof typeof bubbleContentVariants
export type BubbleAlign = keyof typeof bubbleRootAligns
export type BubbleReactionSide = keyof typeof bubbleReactionsSides
export type BubbleReactionAlign = keyof typeof bubbleReactionsAligns
const bubbleSlotRecipe = defineSlotRecipe({ slots: { root: { styles: bubbleRoot, aligns: bubbleRootAligns, variants: bubbleRootVariants, }, content: { styles: bubbleContent, variants: bubbleContentVariants }, group: { styles: bubbleGroup }, }, defaultVariants: { variant: "solid", align: "start" },});
/** * Bubble — framed conversational surface. `<Bubble.Root variant align>` * resolves the recipe once and every part inherits it through context; * `Bubble.Reactions` positions a reaction row on the bubble edge via its * own `side` / `align`. Each part composes via `render`. */const { withProvider, withContext } = createSlotRecipeContext(bubbleSlotRecipe);
type ReactionsProps = WithStyleOverrides<useRender.ComponentProps<"div">> & { side?: BubbleReactionSide; align?: BubbleReactionAlign; };
function Reactions({ side = "bottom", align = "end", palette, style, render, ...rest}: ReactionsProps) { return useRender({ defaultTagName: "div", render, props: mergeProps( { "data-slot": "bubble-reactions", "data-side": side, "data-align": align, ...stylex.props( palette, bubbleReactions.base, bubbleReactionsSides[side], bubbleReactionsAligns[align], style, ), }, rest, ), });}Reactions.displayName = "BubbleReactions";
export const Bubble = { Root: withProvider("div", "root", "BubbleRoot"), Content: withContext("div", "content", "BubbleContent"), Reactions, Group: withProvider("div", "group", "BubbleGroup"),};
Inspired by the Bubble component from shadcn/ui.