Bubble
A chat message bubble aligned to its sender.
Installation
Included in poetry-ui โ available as
poetry_bubble
the moment you've installed Poetry,
with no per-component step. To own the source and edit it, copy it into your app:
bin/rails g poetry:add bubble
Default
Open Default standalone
<div class="flex w-full max-w-md flex-col gap-2">
<%= poetry_bubble(variant: :secondary) { "Here's the summary you asked for." } %>
<%= poetry_bubble(align: :end) { "Sounds good - see you at 10." } %>
</div>
Collapsible
Open Collapsible standalone
<%# Show more / show less without JS state: the visible preview sits above
the collapsible, the remainder IS its panel, and the two trigger labels
+ chevron swap on the root's data-open via in-data-open:. %>
<div class="flex w-full max-w-sm flex-col gap-6">
<%= poetry_bubble(variant: :muted) { "How can I help you today?" } %>
<%= poetry_bubble(variant: :muted, align: :end) do %>
<p>
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 controls inside a layered
surface…
</p>
<%= poetry_collapsible(class: "mt-1") do |collapsible| %>
<% collapsible.with_trigger(class: "flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground") do %>
<span class="in-data-open:hidden">Show more</span>
<span class="hidden in-data-open:inline">Show less</span>
<%= poetry_icon(name: :"chevron-down", class: "size-3.5 transition-transform in-data-open:rotate-180") %>
<% end %>
<div class="space-y-2 pt-2">
<p>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.</p>
<p>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.</p>
</div>
<% end %>
<% end %>
</div>
Group
Open Group standalone
<div class="flex w-full max-w-md flex-col gap-4">
<%= poetry_bubble_group do %>
<%= poetry_bubble(variant: :secondary) { "Did the nightly export run?" } %>
<%= poetry_bubble(variant: :secondary) { "The report still shows yesterday's totals." } %>
<% end %>
<%= poetry_bubble_group do %>
<%= poetry_bubble(align: :end) { "It finished a few minutes ago." } %>
<%= poetry_bubble(align: :end) { "Refresh once and the new totals should land." } %>
<% end %>
</div>
Links and buttons
Open Links and buttons standalone
<%# Quick replies are real controls: tag: :button carries the caller's
data-action, tag: :a + href: is a navigational reply - never a click
handler on a div. %>
<div class="flex w-full max-w-sm flex-col gap-6">
<%= poetry_bubble(variant: :muted) { "How can I help you today?" } %>
<%= poetry_bubble_group(class: "items-end") do %>
<%= poetry_bubble(variant: :tinted, align: :end, tag: :button) { "I forgot my password" } %>
<%= poetry_bubble(variant: :tinted, align: :end, tag: :button) { "I need help with my subscription" } %>
<%= poetry_bubble(variant: :tinted, align: :end, tag: :a, href: "#links-and-buttons") { "Something else. Talk to a human." } %>
<% end %>
</div>
Popover
Open Popover standalone
<%# A reaction can open a popover - error details on demand: the
destructive bubble stays terse, the popover carries the diagnosis.
pb reserves the opened panel's area inside the preview card. %>
<div class="flex w-full max-w-sm flex-col gap-4 pb-32">
<%= poetry_bubble(align: :end) { "Run the build script." } %>
<%= poetry_bubble(variant: :destructive) do |bubble| %>
<% bubble.with_reactions(label: "Error details") do %>
<%= poetry_popover(content_class: "w-80") do |popover| %>
<% popover.with_trigger(variant: :ghost, size: :"icon-xs", label: "Show error details") do %>
<%= poetry_icon(name: :info) %>
<% end %>
<% popover.with_title { "Command failed with exit code 1" } %>
<% popover.with_description { "ENOENT: no such file or directory, open pnpm-lock.yaml" } %>
<% end %>
<% end %>
Failed to run the command.
<% end %>
</div>
Quick replies
Open Quick replies standalone
<div class="flex flex-wrap gap-2">
<%= poetry_bubble(variant: :outline, tag: :button) { "Yes, book it" } %>
<%= poetry_bubble(variant: :outline, tag: :a, href: "/details") { "View details" } %>
</div>
Reactions
Open Reactions standalone
<%# The reactions pill overlays an edge: side:/align: pick which one
(default bottom/end). label: is REQUIRED - it names the cluster for
screen readers. A reaction can be a real control, not just an emoji:
the destructive bubble carries a ghost Button reply. %>
<div class="flex w-full max-w-sm flex-col gap-8">
<%= poetry_bubble(variant: :muted, align: :end) do |bubble| %>
<% bubble.with_reactions(label: "Reactions: thumbs up, surprised", align: :start) do %>
<span>๐</span><span>๐ฎ</span>
<% end %>
I don't need tests, I know my code works.
<% end %>
<%= poetry_bubble(variant: :muted) do |bubble| %>
<% bubble.with_reactions(label: "Reactions: eyes, rocket, and 2 more") do %>
<span>๐</span><span>๐</span><span>+2</span>
<% end %>
Bold. Fine, I'll add some tests. I'll let you know when they're done.
<% end %>
<%= poetry_bubble(align: :end) do |bubble| %>
<% bubble.with_reactions(label: "Reactions: party popper, clapping hands", side: :top, align: :start) do %>
<span>๐</span><span>๐</span>
<% end %>
Tests passed on the first try. All 142 of them. Looking good!
<% end %>
<%= poetry_bubble(variant: :destructive) do |bubble| %>
<% bubble.with_reactions(label: "Confirm running the command") do %>
<%= poetry_button(variant: :ghost, size: :xs) { "Yes, run it" } %>
<% end %>
Are you sure I can run this command?
<% end %>
</div>
Tooltip
Open Tooltip standalone
<%# A reaction can carry a tooltip - the read receipt: an icon-xs ghost
trigger in the reactions pill, the timestamp in the tip. label: names
the icon-only button; the tooltip adds detail on hover/focus. %>
<div class="flex w-full max-w-sm flex-col gap-4">
<%= poetry_bubble(variant: :secondary) { "Did you remove the stale route?" } %>
<%= poetry_bubble(align: :end) do |bubble| %>
<% bubble.with_reactions(label: "Read receipt") do %>
<%= poetry_tooltip do |tooltip| %>
<% tooltip.with_trigger(variant: :ghost, size: :"icon-xs", label: "Read status") do %>
<%= poetry_icon(name: :check) %>
<% end %>
Read on Jan 5, 2026 at 4:32 PM
<% end %>
<% end %>
Yes, removed it from the registry.
<% end %>
</div>
Variants
Open Variants standalone
<div class="flex w-full max-w-md flex-col gap-2">
<%= poetry_bubble { "Sounds good - see you at 10." } %>
<%= poetry_bubble(variant: :secondary) { "Here's the summary you asked for." } %>
<%= poetry_bubble(variant: :muted) { "Typing indicator context." } %>
<%= poetry_bubble(variant: :tinted) { "A tinted assistant reply." } %>
<%= poetry_bubble(variant: :outline) { "An outlined reply." } %>
<%= poetry_bubble(variant: :ghost) { "Tool output flows full-width without a surface." } %>
<%= poetry_bubble(variant: :destructive) { "That upload failed." } %>
</div>
API
Poetry::Ui::Bubble::Component โ options are
constructor keywords (the poetry_* helper forwards them);
slots are composed inside the block. Generated from the gem's source documentation.
| Option | Type | Details | Description |
|---|---|---|---|
| variant: | Symbol | one of: default, secondary, muted, tinted, outline, ghost, destructive; defaults to :default |
The intent axis; :ghost is for tool output / system text flowing full-width. |
| align: | Symbol | one of: start, end; defaults to :start |
Which side the bubble hugs; inside a Message, set the Message's align instead. |
| href: | String | Renders the content as a real anchor; implies tag: :a. | |
| tag: | Symbol | one of: div, button, a; defaults to :div |
The content element: :div (default), or :button/:a for a quick reply. |
Slots
| Writer | Description |
|---|---|
| with_reactions | The reactions pill overlaid on an edge; label: names the cluster for assistive tech, side:/align: place it (default bottom end). |
Styling
Every part carries a stable data-slot attribute โ target
[data-slot=โฆ] from your own CSS to restyle it. State rides
data attributes on the parts below. This contract is verified against rendered DOM in CI.
| Part | Description |
|---|---|
| [data-slot=bubble] | The message surface root - variant and alignment ride here |
| [data-slot=bubble-content] | The body (<div>, or a <button>/<a> quick reply via tag:) - the content block renders here |
| [data-slot=bubble-reactions] | The reactions pill overlay (role=group, named by label:) |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| bubble | data-variant | always - the resolved variant | default ยท secondary ยท muted ยท tinted ยท outline ยท ghost ยท destructive |
| bubble | data-align | always - the resolved align | start ยท end |
| bubble-reactions | data-side | always - which edge the pill overlays (default bottom) | โ |
| bubble-reactions | data-align | always - placement along that edge (default end) | โ |