Bubble
Displays conversational content in a message bubble. Supports variants, alignment, grouping, reactions, and collapsible content.
<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
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:
{%- 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>
{%- 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 }}>
<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>
{%- 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.
<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.
<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.
<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>
Links and Buttons
Turn a bubble into a link or a button with the as prop on Bubble:Content.
<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.
<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.
<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.
<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.
<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.
<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.
<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
BubbleandBubble:Grouprender 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:Contentrenders a<div>by default. Useas="button"oras="a"to make a bubble interactive, which keeps it focusable and keyboard-operable; the component setstype="button"itself whenas="button".- A
Bubble:Reactionsholding emoji needsrole="img"together with anaria-labelthat 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 aBubble:Reactionsthat contains a button, such as aTooltiporPopovertrigger: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-alignanddata-sideattributes 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 The visual style variant.
|
'default'|'secondary'|'muted'|'tinted'|'outline'|'ghost'|'destructive' |
'default' |
align The inline alignment of the bubble.
|
'start'|'end' |
'start' |
| Block | Description |
|---|---|
content |
The bubble content, typically includes Bubble:Content and optionally Bubble:Reactions. |
<twig:Bubble:Content>
| Prop | Type | Default |
|---|---|---|
as The HTML tag to render.
|
'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 The side of the bubble the reactions are anchored to.
|
'top'|'bottom' |
'bottom' |
align The inline alignment of the reactions.
|
'start'|'end' |
'end' |
| Block | Description |
|---|---|
content |
The reactions, typically emoji or small action buttons. |