View as Markdown

Bubble

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

100%
Loading...
<div class="flex w-full max-w-sm flex-col gap-8 py-12">
    <twig:Bubble align="end">
        <twig:Bubble:Content>Hey there! what's up?</twig:Bubble:Content>
    </twig:Bubble>
    <twig:Bubble:Group>
        <twig:Bubble variant="muted">
            <twig:Bubble:Content>Hey! Want to see chat bubbles?</twig:Bubble:Content>
        </twig:Bubble>
        <twig:Bubble variant="muted">
            <twig:Bubble:Content>
                I can group messages, switch sides, and keep the whole thread easy to scan.
            </twig:Bubble:Content>
            <twig:Bubble:Reactions role="img" aria-label="Reaction: thumbs up">
                <span>👍</span>
            </twig:Bubble:Reactions>
        </twig:Bubble>
    </twig:Bubble:Group>
    <twig:Bubble align="end">
        <twig:Bubble:Content>Sure. Hit me with your best demo.</twig:Bubble:Content>
    </twig:Bubble>
    <twig:Bubble variant="muted">
        <twig:Bubble:Content>
            Yes. You are reading a demo that is demoing itself. Very meta. Very on-brand.
        </twig:Bubble:Content>
        <twig:Bubble:Reactions role="img" aria-label="Reactions: thumbs up, fire, eyes, and 2 more">
            <span>👍</span>
            <span>🔥</span>
            <span>👀</span>
            <span>+2</span>
        </twig:Bubble:Reactions>
    </twig:Bubble>
</div>

Installation

Note

Available since UX Toolkit 3.5.

php bin/console ux:install bubble --kit shadcn

Install the following Composer dependencies:

composer require twig/extra-bundle twig/html-extra:^3.24.0 symfony/ux-twig-component:^3.5 tales-from-a-dev/twig-tailwind-extra:^1.3.0

Copy the following file(s) into your app:

templates/components/Bubble.html.twig
{%- props
    ## 'default'|'secondary'|'muted'|'tinted'|'outline'|'ghost'|'destructive' The visual style variant.
    variant = 'default',
    ## 'start'|'end' The inline alignment of the bubble.
    align = 'start'
-%}
{%- set style = html_cva(
    base: 'group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1',
    variants: {
        align: {
            start: '',
            end: 'self-end',
        },
        variant: {
            default: '*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80',
            secondary: '*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]',
            muted: '*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]',
            tinted: '*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]',
            outline: '*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30',
            ghost: 'max-w-full *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50',
            destructive: '*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30',
        },
    },
) -%}
<div
    data-slot="bubble"
    data-variant="{{ variant }}"
    data-align="{{ align }}"
    {{ attributes.defaults({
        class: style.apply({variant: variant, align: align})|tailwind_classes,
    }) }}
>
    {##- The bubble content, typically includes `Bubble:Content` and optionally `Bubble:Reactions`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Bubble/Content.html.twig
{%- props
    ## 'div'|'button'|'a' The HTML tag to render.
    as = 'div'
-%}
<{{ as }}
    data-slot="bubble-content"
    {{ attributes.defaults({
        class: 'w-fit max-w-full min-w-0 overflow-hidden rounded-3xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-start [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50'|tailwind_classes,
        type: as == 'button' ? 'button' : false,
    }) }}
>
    {##- The message content displayed inside the bubble. -#}
    {%- block content %}{% endblock -%}
</{{ as }}>
templates/components/Bubble/Group.html.twig
<div
    data-slot="bubble-group"
    {{ attributes.defaults({
        class: 'flex min-w-0 flex-col gap-2'|tailwind_classes,
    }) }}
>
    {##- The grouped bubbles, typically multiple `Bubble` components from the same sender. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Bubble/Reactions.html.twig
{%- props
    ## 'top'|'bottom' The side of the bubble the reactions are anchored to.
    side = 'bottom',
    ## 'start'|'end' The inline alignment of the reactions.
    align = 'end'
-%}
{%- set style = html_cva(
    base: 'absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0',
    variants: {
        side: {
            top: 'top-0 -translate-y-3/4',
            bottom: 'bottom-0 translate-y-3/4',
        },
        align: {
            start: 'start-3',
            end: 'end-3',
        },
    },
) -%}
<div
    data-slot="bubble-reactions"
    data-side="{{ side }}"
    data-align="{{ align }}"
    {{ attributes.defaults({
        class: style.apply({side: side, align: align})|tailwind_classes,
    }) }}
>
    {##- The reactions, typically emoji or small action buttons. -#}
    {%- block content %}{% endblock -%}
</div>

Usage

<twig:Bubble variant="default" align="start">
    <twig:Bubble:Content>
        I checked the registry output and removed the stale route.
    </twig:Bubble:Content>
    <twig:Bubble:Reactions side="bottom" align="end">
        <span>👍</span>
    </twig:Bubble:Reactions>
</twig:Bubble>

Examples

Variants

Use the variant prop to change the visual treatment of the bubble. 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.

100%
Loading...
<div class="flex w-full max-w-sm flex-col gap-12 py-12">
    <twig:Bubble>
        <twig:Bubble:Content>This is the default primary bubble.</twig:Bubble:Content>
    </twig:Bubble>
    <twig:Bubble variant="secondary" align="end">
        <twig:Bubble:Content>This is the secondary variant.</twig:Bubble:Content>
    </twig:Bubble>
    <twig:Bubble variant="muted">
        <twig:Bubble:Content>
            This one is muted. It uses a lower emphasis color for the chat bubble.
        </twig:Bubble:Content>
        <twig:Bubble:Reactions role="img" aria-label="Reaction: thumbs up">
            <span>👍</span>
        </twig:Bubble:Reactions>
    </twig:Bubble>
    <twig:Bubble variant="tinted" align="end">
        <twig:Bubble:Content>
            This one is tinted. The tint is a softer color derived from the primary color.
        </twig:Bubble:Content>
    </twig:Bubble>
    <twig:Bubble variant="outline">
        <twig:Bubble:Content>We can also use an outlined variant.</twig:Bubble:Content>
    </twig:Bubble>
    <twig:Bubble variant="destructive" align="end">
        <twig:Bubble:Content>Or a destructive variant with a reaction.</twig:Bubble:Content>
        <twig:Bubble:Reactions role="img" aria-label="Reaction: fire">
            <span>🔥</span>
        </twig:Bubble:Reactions>
    </twig:Bubble>
    <twig:Bubble variant="ghost">
        <twig:Bubble:Content>Ghost bubbles are unframed and span the full width of the conversation.</twig:Bubble:Content>
    </twig:Bubble>
</div>

Alignment

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

100%
Loading...
<div class="flex w-full max-w-sm flex-col gap-8 py-12">
    <twig:Bubble variant="muted">
        <twig:Bubble:Content>
            This bubble is aligned to the start. This is the default alignment.
        </twig:Bubble:Content>
    </twig:Bubble>
    <twig:Bubble align="end">
        <twig:Bubble:Content>
            This bubble is aligned to the end. Use this for user messages.
        </twig:Bubble:Content>
    </twig:Bubble>
</div>

Bubble Group

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

100%
Loading...
<div class="flex w-full max-w-sm flex-col gap-8 py-12">
    <twig:Bubble variant="muted">
        <twig:Bubble:Content>Can you tell me what's the issue?</twig:Bubble:Content>
    </twig:Bubble>
    <twig:Bubble:Group>
        <twig:Bubble align="end">
            <twig:Bubble:Content>You tell me!</twig:Bubble:Content>
        </twig:Bubble>
        <twig:Bubble align="end">
            <twig:Bubble:Content>It worked yesterday. You broke it!</twig:Bubble:Content>
        </twig:Bubble>
        <twig:Bubble align="end">
            <twig:Bubble:Content>Find the bug and fix it.</twig:Bubble:Content>
            <twig:Bubble:Reactions align="start" role="img" aria-label="Reactions: eyes">
                <span>👀</span>
            </twig:Bubble:Reactions>
        </twig:Bubble>
    </twig:Bubble:Group>
    <twig:Bubble variant="muted">
        <twig:Bubble:Content>
            Want me to diff yesterday's you against today's you? It's a bit embarrassing.
        </twig:Bubble:Content>
    </twig:Bubble>
</div>

Turn a bubble into a link or a button with the as prop on Bubble:Content.

100%
Loading...
<div class="flex w-full max-w-sm flex-col gap-8 py-12">
    <twig:Bubble variant="muted">
        <twig:Bubble:Content>How can I help you today?</twig:Bubble:Content>
    </twig:Bubble>
    <twig:Bubble:Group>
        <twig:Bubble variant="tinted" align="end">
            <twig:Bubble:Content as="button">I forgot my password</twig:Bubble:Content>
        </twig:Bubble>
        <twig:Bubble variant="tinted" align="end">
            <twig:Bubble:Content as="button">I need help with my subscription</twig:Bubble:Content>
        </twig:Bubble>
        <twig:Bubble variant="tinted" align="end">
            <twig:Bubble:Content as="a" href="#">Something else. Talk to a human.</twig:Bubble:Content>
        </twig:Bubble>
    </twig:Bubble:Group>
</div>

Reactions

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

100%
Loading...
<div class="flex w-full max-w-sm flex-col gap-12 py-12">
    <twig:Bubble variant="muted" align="end">
        <twig:Bubble:Content>I don't need tests, I know my code works.</twig:Bubble:Content>
        <twig:Bubble:Reactions align="start" role="img" aria-label="Reactions: thumbs up, surprised">
            <span>👍</span>
            <span>😮</span>
        </twig:Bubble:Reactions>
    </twig:Bubble>
    <twig:Bubble variant="muted">
        <twig:Bubble:Content>
            Bold. Fine I'll add some tests. I'll let you know when they're done.
        </twig:Bubble:Content>
        <twig:Bubble:Reactions role="img" aria-label="Reactions: eyes, rocket, and 2 more">
            <span>👀</span>
            <span>🚀</span>
            <span>+2</span>
        </twig:Bubble:Reactions>
    </twig:Bubble>
    <twig:Bubble variant="default" align="end">
        <twig:Bubble:Content>
            Tests passed on the first try. All 142 of them. Looking good!
        </twig:Bubble:Content>
        <twig:Bubble:Reactions side="top" align="start" role="img" aria-label="Reactions: party popper, clapping hands">
            <span>🎉</span>
            <span>👏</span>
        </twig:Bubble:Reactions>
    </twig:Bubble>
    <twig:Bubble variant="destructive">
        <twig:Bubble:Content>Are you sure I can run this command?</twig:Bubble:Content>
        <twig:Bubble:Reactions>
            <twig:Button variant="ghost" size="xs">Yes, run it</twig:Button>
        </twig:Bubble:Reactions>
    </twig:Bubble>
</div>

Show More / Collapsible

Long bubble content can be composed with Collapsible to allow for a show more or show less interaction.

100%
Loading...
<div class="flex w-full max-w-sm flex-col gap-8 py-12">
    <twig:Bubble variant="muted">
        <twig:Bubble:Content>How can I help you today?</twig:Bubble:Content>
    </twig:Bubble>
    <twig:Bubble variant="muted" align="end">
        <twig:Bubble:Content>
            <twig:Collapsible class="flex flex-col gap-2">
                <div>
                    The accessibility review found two focus states that were visually too subtle in dark mode.
                </div>
                <twig:Collapsible:Content class="flex flex-col gap-4">
                    <div>
                        I checked the dialog, menu, and drawer paths because each one renders focusable controls inside a layered surface.
                    </div>
                    <div>
                        The 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.
                    </div>
                    <div>
                        I also recommend keeping the change in the style file instead of the primitive so the other themes can choose their own focus treatment later.
                    </div>
                </twig:Collapsible:Content>
                <twig:Collapsible:Trigger>
                    <twig:Button variant="link" class="group w-fit gap-1 p-0 text-muted-foreground" {{ ...collapsible_trigger_attrs }}>
                        <span class="group-data-[state=open]:hidden">Show more</span>
                        <span class="hidden group-data-[state=open]:inline">Show less</span>
                        <twig:ux:icon name="lucide:chevron-down" class="size-4 transition-transform group-data-[state=open]:rotate-180" />
                    </twig:Button>
                </twig:Collapsible:Trigger>
            </twig:Collapsible>
        </twig:Bubble:Content>
    </twig:Bubble>
</div>

Tooltip

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

100%
Loading...
<div class="flex w-full max-w-sm flex-col gap-4 py-12">
    <twig:Bubble variant="secondary">
        <twig:Bubble:Content>Did you remove the stale route?</twig:Bubble:Content>
    </twig:Bubble>
    <twig:Bubble align="end">
        <twig:Bubble:Content>Yes, removed it from the registry.</twig:Bubble:Content>
        <twig:Bubble:Reactions>
            <twig:Tooltip id="bubble-read-receipt">
                <twig:Tooltip:Trigger>
                    <twig:Button variant="ghost" size="icon-xs" aria-label="Read receipt" {{ ...tooltip_trigger_attrs }}>
                        <twig:ux:icon name="lucide:check" />
                    </twig:Button>
                </twig:Tooltip:Trigger>
                <twig:Tooltip:Content>Read on Jan 5, 2026 at 4:32 PM</twig:Tooltip:Content>
            </twig:Tooltip>
        </twig:Bubble:Reactions>
    </twig:Bubble>
</div>

Popover

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

100%
Loading...
<div class="flex w-full max-w-sm flex-col gap-4 py-12" style="min-height: 240px">
    <twig:Bubble align="end">
        <twig:Bubble:Content>Run the build script.</twig:Bubble:Content>
    </twig:Bubble>
    <twig:Bubble variant="destructive">
        <twig:Bubble:Content>Failed to run the command.</twig:Bubble:Content>
        <twig:Bubble:Reactions>
            <twig:Popover>
                <twig:Popover:Trigger>
                    <twig:Button variant="ghost" size="icon-xs" aria-label="Show error details" class="aria-expanded:text-destructive" {{ ...popover_trigger_attrs }}>
                        <twig:ux:icon name="lucide:info" />
                    </twig:Button>
                </twig:Popover:Trigger>
                <twig:Popover:Content align="end">
                    <div class="flex flex-col gap-1.5">
                        <p class="text-sm font-medium">Command failed with exit code 1</p>
                        <p class="text-sm text-muted-foreground">ENOENT: no such file or directory, open pnpm-lock.yaml</p>
                    </div>
                </twig:Popover:Content>
            </twig:Popover>
        </twig:Bubble:Reactions>
    </twig:Bubble>
</div>

Markdown

Ghost bubbles are a good fit for rendered markdown, since they are unframed and span the full width of the conversation.

100%
Loading...
<div class="flex w-full max-w-sm flex-col gap-8 py-12">
    <twig:Bubble align="end" variant="muted">
        <twig:Bubble:Content>Hello! Are you actually <strong>thinking</strong>?</twig:Bubble:Content>
    </twig:Bubble>
    <twig:Bubble variant="ghost">
        <twig:Bubble:Content class="flex flex-col gap-4">
            <p>Ghost bubbles work for assistant text, <strong>markdown</strong>, and other content that should not be framed.</p>
            <p>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 class="rounded bg-muted px-1 py-0.5 text-xs">code</code> in it.</p>
            <p>Ghost bubbles are full width and can take the full width of the container.</p>
        </twig:Bubble:Content>
    </twig:Bubble>
</div>

RTL

To enable RTL support, set the dir="rtl" attribute on the root element.

100%
Loading...
<div class="flex w-full flex-col items-center gap-8">
    {# Arabic #}
    <div class="flex w-full max-w-sm flex-col gap-10 py-10" dir="rtl">
        <twig:Bubble variant="muted">
            <twig:Bubble:Content>هل يمكنك إخباري بما هي المشكلة؟</twig:Bubble:Content>
        </twig:Bubble>
        <twig:Bubble align="end">
            <twig:Bubble:Content>لقد نجحت بالأمس. أنت من عطّلها!</twig:Bubble:Content>
            <twig:Bubble:Reactions align="start" role="img" aria-label="التفاعلات: عيون">
                <span>👀</span>
            </twig:Bubble:Reactions>
        </twig:Bubble>
    </div>

    {# Hebrew #}
    <div class="flex w-full max-w-sm flex-col gap-10 py-10" dir="rtl">
        <twig:Bubble variant="muted">
            <twig:Bubble:Content>תוכל להגיד לי מה הבעיה?</twig:Bubble:Content>
        </twig:Bubble>
        <twig:Bubble align="end">
            <twig:Bubble:Content>זה עבד אתמול. אתה שברת את זה!</twig:Bubble:Content>
            <twig:Bubble:Reactions align="start" role="img" aria-label="תגובות: עיניים">
                <span>👀</span>
            </twig:Bubble:Reactions>
        </twig:Bubble>
    </div>
</div>

Accessibility

  • Bubble and Bubble:Group render plain <div>s and add no role, so they are announced as ordinary content: who sent a message must be clear from the text or surrounding markup, not from the bubble's colour or alignment.
  • Bubble:Content renders a <div> by default. Use as="button" or as="a" to make a bubble interactive, which keeps it focusable and keyboard-operable; the component sets type="button" itself when as="button".
  • A Bubble:Reactions holding emoji needs role="img" together with an aria-label that names the reactions, otherwise screen readers announce the raw emoji characters one by one, e.g. aria-label="Reactions: thumbs up, fire, eyes, and 2 more".
  • Do not put role="img" on a Bubble:Reactions that contains a button, such as a Tooltip or Popover trigger: role="img" makes its descendants presentational, hiding the button from assistive tech and making it unreachable. The Tooltip and Popover examples in this README deliberately omit the role for that reason.
  • The data-variant, data-align and data-side attributes are styling hooks only and carry no semantics.
  • For a conversation that receives messages after load, put role="log" on the container that wraps the bubbles so new messages are announced as they arrive. That container belongs to the consumer, not to this recipe.

API Reference

<twig:Bubble>

Prop Type Default
variant 
'default'|'secondary'|'muted'|'tinted'|'outline'|'ghost'|'destructive' 'default'
align 
'start'|'end' 'start'
Block Description
content The bubble content, typically includes Bubble:Content and optionally Bubble:Reactions.

<twig:Bubble:Content>

Prop Type Default
as 
'div'|'button'|'a' 'div'
Block Description
content The message content displayed inside the bubble.

<twig:Bubble:Group>

Block Description
content The grouped bubbles, typically multiple Bubble components from the same sender.

<twig:Bubble:Reactions>

Prop Type Default
side 
'top'|'bottom' 'bottom'
align 
'start'|'end' 'end'
Block Description
content The reactions, typically emoji or small action buttons.