Bubble

Displays conversational content in a message bubble. Supports variants, alignment, grouping, reactions, and collapsible content.

The Bubble component displays framed conversational content. Use it for chat text, short structured output, quoted replies, suggestions, and reactions.

For full-featured chat interfaces, use the Message component. Bubble is intentionally scoped to the bubble surface. Place avatars, names, timestamps, metadata, and message-level actions in Message.

Installation

Copy the following code into your app directory.

uv

uv run buridan add component bubble
from components.ui.bubble import bubble

Anatomy

Use the following composition to build a Bubble component.

bubble.root( bubble.content(), bubble.reactions(), )

Features

  • Seven visual variants, from a strong primary bubble to unframed ghost content

  • Start and end alignment for sender and receiver bubbles

  • Reactions that anchor to the bubble edge with configurable side and alignment

  • Bubbles size to their content, up to 80% of the container width

  • Polymorphic content via render for link and button bubbles

  • Customizable styling through the class_name prop on every part

Examples

Variants

Use variant to change the visual treatment of the bubble.

This is the default primary bubble.
This is the secondary variant.
This one is muted. It uses a lower emphasis color for the chat bubble.
This one is tinted. The tint is a softer color derived from the primary color.
We can also use an outlined variant.
Or a destructive variant with a reaction.

Ghost bubbles work for assistant text, markdown, and other content that should not be framed.

This is perfect for assistant messages that should not have a frame and can take the full width of the container. You can also render code in it.

Ghost bubbles are full width and can take the full width of the container.

import reflex as rx

from components.ui.bubble import bubble


def bubble_with_variants():
    return rx.el.div(
        bubble.root(
            bubble.content("This is the default primary bubble."),
            variant="default",
        ),
        bubble.root(
            bubble.content("This is the secondary variant."),
            variant="secondary",
            align="end",
        ),
        bubble.root(
            bubble.content(
                "This one is muted. It uses a lower emphasis color for the chat bubble."
            ),
            bubble.reactions(
                rx.el.span("👍"),
                role="img",
                aria_label="Reaction: thumbs up",
            ),
            variant="muted",
        ),
        bubble.root(
            bubble.content(
                "This one is tinted. The tint is a softer color derived from the primary color."
            ),
            variant="tinted",
            align="end",
        ),
        bubble.root(
            bubble.content("We can also use an outlined variant."),
            variant="outline",
        ),
        bubble.root(
            bubble.content("Or a destructive variant with a reaction."),
            bubble.reactions(
                rx.el.span("🔥"),
                role="img",
                aria_label="Reaction: fire",
            ),
            variant="destructive",
            align="end",
        ),
        bubble.root(
            bubble.content(
                rx.markdown(
                    """
                    Ghost bubbles work for assistant text, **markdown**, and other content that should not be framed.

                    This is perfect for assistant messages that should not have a frame and can take the full width of the container. You can also render `code` in it.

                    Ghost bubbles are full width and can take the full width of the container.
                    """
                )
            ),
            variant="ghost",
        ),
        class_name="flex w-full max-w-sm flex-col gap-12 py-12",
    )
VariantDescription
defaultA strong primary bubble, usually for the current user.
secondaryThe standard neutral bubble for conversation content.
mutedA lower-emphasis bubble for quiet supporting content.
tintedA subtle primary-tinted bubble.
outlineA bordered bubble for secondary or rich content.
ghostUnframed content for assistant text or rich content.
destructiveA destructive bubble for error or failed actions.

A bubble sizes to its content, up to 80% of the container width. The ghost variant removes the max-width so assistant text and rich content can span the full row.

Alignment

Use align on bubble.root to align the bubble to the start or end of the conversation.

This bubble is aligned to the start. This is the default alignment.
This bubble is aligned to the end. Use this for user messages.
import reflex as rx

from components.ui.bubble import bubble


def bubble_alignment_demo():
    return rx.el.div(
        bubble.root(
            bubble.content(
                "This bubble is aligned to the start. This is the default alignment."
            ),
            variant="muted",
            align="start",
        ),
        bubble.root(
            bubble.content(
                "This bubble is aligned to the end. Use this for user messages."
            ),
            align="end",
        ),
        class_name="flex w-full max-w-sm flex-col gap-8 py-12",
    )
alignDescription
startAlign the bubble to the start of the conversation.
endAlign the bubble to the end of the conversation.

Note: When building chat interfaces, you probably want to use alignment on the Message component itself, not the Bubble component. You can use the role prop on the message.root component to automatically align the bubble to the start or end of the conversation.

Bubble Group

Use bubble.group to group consecutive bubbles from the same sender. Note the align prop should be set on the bubble.root component itself, not the bubble.group component.

composition

bubble.group
├── bubble.root
│   └── bubble.content
└── bubble.root
    └── bubble.content
Can you tell me what's the issue?
You tell me!
It worked yesterday. You broke it!
Find the bug and fix it.
👀
Want me to diff yesterday's you against today's you? It's a bit embarrassing.
import reflex as rx

from components.ui.bubble import bubble


def bubble_group_demo():
    return rx.el.div(
        bubble.root(
            bubble.content("Can you tell me what's the issue?"),
            variant="muted",
        ),
        bubble.group(
            bubble.root(
                bubble.content("You tell me!"),
                align="end",
            ),
            bubble.root(
                bubble.content("It worked yesterday. You broke it!"),
                align="end",
            ),
            bubble.root(
                bubble.content("Find the bug and fix it."),
                bubble.reactions(
                    rx.el.span("👀"),
                    aria_label="Reactions: eyes",
                    align="start",
                ),
                align="end",
            ),
        ),
        bubble.root(
            bubble.content(
                "Want me to diff yesterday's you against today's you? "
                "It's a bit embarrassing."
            ),
            variant="muted",
        ),
        class_name="flex w-full max-w-sm flex-col gap-8 py-12",
    )

You can turn a bubble into a link or button by using the passing the interactive elements directly into the bubble.content slot. The bubble.content accepts *children so simply placing a button or link will render that component.

How can I help you today?
import reflex as rx

from components.ui.bubble import bubble


def bubble_link_button_demo():
    return rx.el.div(
        bubble.root(
            bubble.content("How can I help you today?"),
            variant="muted",
        ),
        bubble.group(
            bubble.root(
                bubble.content(
                    rx.el.button(
                        "I forgot my password",
                        on_click=rx.toast("You clicked forgot password"),
                        class_name="w-full text-left",
                    )
                ),
                variant="tinted",
                align="end",
            ),
            bubble.root(
                bubble.content(
                    rx.el.button(
                        "I need help with my subscription",
                        on_click=rx.toast("You clicked help with subscription"),
                        class_name="w-full text-left",
                    )
                ),
                variant="tinted",
                align="end",
            ),
            bubble.root(
                bubble.content(
                    rx.el.button(
                        "Something else. Talk to a human.",
                        on_click=rx.toast(
                            "You clicked something else. Talk to a human."
                        ),
                        class_name="w-full text-left",
                    )
                ),
                variant="tinted",
                align="end",
            ),
        ),
        class_name="flex w-full max-w-sm flex-col gap-8 py-12",
    )

Reactions

Use bubble.reactions for bubble reactions. You can use it to display reactions or quick action buttons. Use side and align to position the row — side="top" anchors it to the upper edge. Reactions overlap the bubble edge, so leave vertical space between rows — the examples below use a larger gap for this reason.

I don't need tests, I know my code works.
Bold. Fine I'll add some tests. I'll let you know when they're done.
Tests passed on the first try. All 142 of them. Looking good!
Are you sure I can run this command?
import reflex as rx

from components.ui.bubble import bubble


def bubble_reactions_demo():
    return rx.el.div(
        bubble.root(
            bubble.content("I don't need tests, I know my code works."),
            bubble.reactions(
                rx.el.span("👍"),
                rx.el.span("😮"),
                align="start",
                role="img",
                aria_label="Reactions: thumbs up, surprised",
            ),
            variant="muted",
            align="end",
        ),
        bubble.root(
            bubble.content(
                "Bold. Fine I'll add some tests. I'll let you know when they're done."
            ),
            bubble.reactions(
                rx.el.span("👀"),
                rx.el.span("🚀"),
                rx.el.span("+2"),
                role="img",
                aria_label="Reactions: eyes, rocket, and 2 more",
            ),
            variant="muted",
        ),
        bubble.root(
            bubble.content(
                "Tests passed on the first try. All 142 of them. Looking good!"
            ),
            bubble.reactions(
                rx.el.span("🎉"),
                rx.el.span("👏"),
                side="top",
                align="start",
                role="img",
                aria_label="Reactions: party popper, clapping hands",
            ),
            variant="default",
            align="end",
        ),
        bubble.root(
            bubble.content("Are you sure I can run this command?"),
            bubble.reactions(
                rx.el.button(
                    "Yes, run it",
                    on_click=rx.toast.success("You clicked yes, running command..."),
                    class_name="px-2 py-0.5 text-xs hover:bg-accent rounded-md",
                ),
            ),
            variant="destructive",
        ),
        class_name="flex w-full max-w-sm flex-col gap-12 py-12",
    )

Show More / Collapsible

Long bubble content can be composed with Collapsible to allow for a show more or show less interaction. Use the collapsible.trigger component to trigger the collapsible content.

How can I help you today?
The accessibility review found two focus states that were visually too subtle in dark mode. I checked the dialog, menu, and drawer paths because each one renders focusable control...
import reflex as rx
from reflex.experimental import ClientStateVar

from components.icons.hugeicon import hi
from components.ui.bubble import bubble
from components.ui.collapsible import collapsible

open_var = ClientStateVar.create("open_var", False)
text_val = "The accessibility review found two focus states that were visually too subtle in dark mode.\n\nI checked the dialog, menu, and drawer paths because each one renders focusable controls inside a layered surface.\n\nThe dialog and drawer are fine. The menu needs the hover and focus tokens split so keyboard focus stays visible when the pointer is not involved.\n\nI also recommend keeping the change in the style file instead of the primitive so the other themes can choose their own focus treatment later."


def bubble_collapsible_demo():
    return rx.el.div(
        bubble.root(
            bubble.content("How can I help you today?"),
            variant="muted",
        ),
        bubble.root(
            bubble.content(
                collapsible.root(
                    rx.el.div(
                        rx.cond(open_var.value, text_val, f"{text_val[:180]}..."),
                        class_name="whitespace-pre-line",
                    ),
                    collapsible.trigger(
                        rx.el.button(
                            rx.cond(open_var.value, "Show less", "Show more"),
                            rx.cond(
                                open_var.value,
                                hi("ArrowUp01Icon"),
                                hi("ArrowDown01Icon"),
                            ),
                            class_name="flex flex-row items-center gap-1 p-0 text-muted-foreground hover:underline",
                        ),
                    ),
                    open=open_var.value,
                    on_open_change=open_var.set_value,
                ),
            ),
            variant="muted",
            align="end",
        ),
        class_name="flex w-full max-w-sm flex-col gap-8 py-12",
    )

Tooltip

Wrap a bubble in a Tooltip to reveal metadata on hover, such as when a message was read.

Did you remove the stale route?
Yes, removed it from the registry.
import reflex as rx

from components.icons.hugeicon import hi
from components.ui.bubble import bubble
from components.ui.button import button
from components.ui.tooltip import tooltip


def bubble_tooltip_demo():
    return rx.el.div(
        bubble.root(
            bubble.content("Did you remove the stale route?"),
            variant="secondary",
        ),
        bubble.root(
            bubble.content("Yes, removed it from the registry."),
            bubble.reactions(
                tooltip.provider(
                    tooltip.root(
                        tooltip.trigger(
                            render_=button(
                                hi("Tick02Icon", class_name="size-4"),
                                variant="ghost",
                                class_name="w-6 h-6",
                            )
                        ),
                        tooltip.portal(
                            tooltip.positioner(
                                tooltip.popup(
                                    "Read on Jan 5, 2026 at 4:32 PM",
                                    tooltip.arrow(),
                                ),
                                side="bottom",
                            )
                        ),
                    ),
                    delay=0,
                )
            ),
            align="end",
        ),
        class_name="flex w-full max-w-sm flex-col gap-4 py-12",
    )

Popover

Pair a bubble with a Popover to surface more information on demand, such as the full error message for a failed action.

Run the build script.
Failed to run the command.
import reflex as rx

from components.icons.hugeicon import hi
from components.ui.bubble import bubble
from components.ui.button import button
from components.ui.popover import popover


def bubble_popover_demo():
    return rx.el.div(
        bubble.root(
            bubble.content("Run the build script."),
            align="end",
        ),
        bubble.root(
            bubble.content("Failed to run the command."),
            bubble.reactions(
                popover.root(
                    popover.trigger(
                        render_=button(
                            hi("InformationCircleIcon"),
                            variant="ghost",
                            aria_label="Show error details",
                            class_name="w-6 h-6 aria-expanded:text-destructive",
                        )
                    ),
                    popover.portal(
                        popover.backdrop(),
                        popover.positioner(
                            popover.popup(
                                popover.header(
                                    popover.title(
                                        "Command failed with exit code 1",
                                        class_name="text-sm",
                                    ),
                                    popover.description(
                                        "ENOENT: no such file or directory, open pnpm-lock.yaml",
                                        class_name="text-sm",
                                    ),
                                ),
                            ),
                        ),
                    ),
                )
            ),
            variant="destructive",
        ),
        class_name="flex w-full max-w-sm flex-col gap-4 py-12",
    )

Accessibility

bubble.root renders the presentational message surface. Keep conversation-level semantics on the surrounding container and follow the guidelines below.

Labeling Reactions

Reactions render as a row of emoji. A screen reader reads each glyph with no context, and counters like +8 are announced as "plus eight". Group the row as a single image with a descriptive aria_label so it announces once. role="img" also hides the individual emoji from assistive tech, so no aria_hidden is needed.

python

bubble.reactions(
    rx.el.span("👍"),
    rx.el.span("🔥"),
    rx.el.span("+8"),
    role="img",
    aria_label="Reactions: thumbs up, fire, and 8 more"
)

When reactions are interactive, render buttons instead and give icon-only buttons an aria_label.

python

bubble.reactions(
    button(
        ...,
        aria_label="Thumbs up",
        variant="secondary",
        size="sm"
    )
)

Interactive Bubbles

When a bubble is clickable, render it as a real <button> or <a>. bubble.-* content accept *children so simply passing in the interactive component will get rendered. bubble.content ships a visible focus ring for interactive elements, and the accessible name comes from the bubble text. No extra label is needed.

python

bubble.root(
    bubble.content(
        "I forgot my password",
        rx.el.button(type="button", on_click=on_reply)
    ),
    variant="muted",
    align="end"
)

Meaning Beyond Color

Bubble variants signal role and tone with color. Pair them with text, alignment, or icons so meaning is not conveyed by color alone. For a destructive bubble, keep the error context in the message text rather than relying on the color treatment.

API Reference

bubble.root

The root bubble wrapper.

PropTypeDefaultDescription
variant"default" | "secondary" | "muted" | "tinted" | "outline" | "ghost" | "destructive""default"The bubble visual treatment.
align"start" | "end""start"The inline alignment of the bubble.
class_namestring-Additional classes to apply to the root element.

bubble.content

The bubble content wrapper.

PropTypeDefaultDescription
*childrenrx.Component-Render the content as a different element such as a link.
class_namestring-Additional classes to apply to the content element.

bubble.reactions

Displays overlapped reactions for a bubble.

PropTypeDefaultDescription
side"top" | "bottom""bottom"The side of the bubble to anchor the reactions.
align"start" | "end""end"The inline alignment of the reactions.
class_namestring-Additional classes to apply to the reaction row.

bubble.group

Groups consecutive bubbles from the same sender.

PropTypeDefaultDescription
class_namestring-Additional classes to apply to the group root.