Popover
Rich floating content anchored to a trigger.
Installation
Included in poetry-ui — available as
poetry_popover
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 popover
Default
Set the dimensions for the layer.
<%# pb reserves the opened panel's area (168px + popper gap) inside the
preview card - the fixed-position layer adds no layout height. %>
<div class="pb-44">
<%= poetry_popover(content_class: "w-80") do |popover| %>
<% popover.with_trigger(variant: :outline) { "Open popover" } %>
<% popover.with_title { "Dimensions" } %>
<% popover.with_description { "Set the dimensions for the layer." } %>
<div class="grid gap-2">
<div class="grid grid-cols-3 items-center gap-4">
<%= poetry_label(for_id: "popover-demo-width") { "Width" } %>
<%= poetry_input(name: "width", value: "100%", id: "popover-demo-width", class: "col-span-2 h-8") %>
</div>
<div class="grid grid-cols-3 items-center gap-4">
<%= poetry_label(for_id: "popover-demo-height") { "Height" } %>
<%= poetry_input(name: "height", value: "25px", id: "popover-demo-height", class: "col-span-2 h-8") %>
</div>
</div>
<% end %>
</div>
Align
The panel aligns to the start edge of its trigger.
The panel aligns to the center edge of its trigger.
The panel aligns to the end edge of its trigger.
<%# pb reserves the opened panels' area inside the preview card - the
fixed-position layer adds no layout height. %>
<div class="pb-36">
<div class="flex flex-wrap gap-2">
<% %i[start center end].each do |alignment| %>
<%= poetry_popover(align: alignment, content_class: "w-64") do |popover| %>
<% popover.with_trigger(variant: :outline) { "align: #{alignment}" } %>
<% popover.with_title { "Alignment" } %>
<% popover.with_description { "The panel aligns to the #{alignment} edge of its trigger." } %>
<% end %>
<% end %>
</div>
</div>
Basic
A shared space for the design and research teams to plan releases.
<%# pb reserves the opened panel's area inside the preview card - the
fixed-position layer adds no layout height. %>
<div class="pb-36">
<%= poetry_popover(content_class: "w-72") do |popover| %>
<% popover.with_trigger(variant: :outline) { "View details" } %>
<% popover.with_title { "Aurora workspace" } %>
<% popover.with_description { "A shared space for the design and research teams to plan releases." } %>
Members can comment, attach files, and track open questions in one place.
<% end %>
</div>
Modal
Focus is trapped; outside interaction dismisses instead of passing through.
<%# pb reserves the opened panel's area (136px + popper gap) inside the
preview card - the fixed-position layer adds no layout height. %>
<div class="pb-36">
<%= poetry_popover(modal: true, content_class: "w-80") do |popover| %>
<% popover.with_trigger(variant: :outline) { "Open modal popover" } %>
<% popover.with_title { "Modal" } %>
<% popover.with_description { "Focus is trapped; outside interaction dismisses instead of passing through." } %>
Press Escape or click outside to dismiss.
<% end %>
</div>
Open
Rendered open - fully usable before (or without) JS.
<%# The wrapper reserves the open panel's area inside the preview card:
the popper-positioned content contributes no layout height, so without
it the panel floats over the next section (z-50). %>
<div class="pb-40">
<%= poetry_popover(open: true) do |popover| %>
<% popover.with_trigger(variant: :outline) { "Open popover" } %>
<% popover.with_title { "Server-opened" } %>
<% popover.with_description { "Rendered open - fully usable before (or without) JS." } %>
Static content.
<% end %>
</div>
Side top
<%# pt reserves the upward-opening panel's area inside the preview card. %>
<div class="pt-12">
<%= poetry_popover(side: :top, align: :start) do |popover| %>
<% popover.with_trigger(variant: :outline) { "Open above" } %>
<% popover.with_title { "Placement" } %>
side: :top, align: :start
<% end %>
</div>
With anchor
<%# pb reserves the opened panel area - the panel positions against the
ANCHOR part (a popper target; targets beat the trigger selector in
the fallback chain), not the button. %>
<div class="pb-36">
<%= poetry_popover do |popover| %>
<% popover.with_trigger(variant: :outline) { "Open popover" } %>
<% popover.with_anchor { "The panel anchors to this text, not the button." } %>
<% popover.with_title { "Anchored" } %>
Positioned against the anchor part.
<% end %>
</div>
With form
Set the dimensions for the layer.
<%# Upstream popover-form: the dimensions form as label+input rows with
half-width labels (their Field orientation=horizontal; composed as
plain rows here - poetry reserves field horizontal for boolean
controls). pb reserves the opened panel's area in the preview card. %>
<div class="pb-52">
<%= poetry_popover(align: :start, content_class: "w-64") do |popover| %>
<% popover.with_trigger(variant: :outline) { "Open Popover" } %>
<% popover.with_title { "Dimensions" } %>
<% popover.with_description { "Set the dimensions for the layer." } %>
<form class="grid gap-4">
<div class="flex items-center">
<%= poetry_label(for_id: "popover-form-width", class: "w-1/2") { "Width" } %>
<%= poetry_input(name: "width", value: "100%", id: "popover-form-width") %>
</div>
<div class="flex items-center">
<%= poetry_label(for_id: "popover-form-height", class: "w-1/2") { "Height" } %>
<%= poetry_input(name: "height", value: "25px", id: "popover-form-height") %>
</div>
</form>
<% end %>
</div>
API
Poetry::Ui::Popover::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 |
|---|---|---|---|
| content_class: | String | Class merge seam for the panel itself (e.g. widen the default with content_class: \"w-80\") - root-level class: styles the wrapper, not the panel. | |
| label: | String | role=dialog fallback name when no title part is present. | |
| modal: | Boolean | defaults to false |
Reserves interaction for the panel while open; the default keeps the rest of the page interactive. |
| open: | Boolean | defaults to false |
Server-renders the panel open. |
Slots
| Writer | Description |
|---|---|
| with_anchor | Optional alternate anchor: when present, the panel positions against IT instead of the trigger (targets beat selectors in the positioning fallback chain). |
| with_description | Supporting text - presence wires the content's aria-describedby. |
| with_title | Panel heading - presence wires the content's aria-labelledby, so the title names the dialog. |
| with_trigger | The control that opens the panel - a composed Button. The slot owns the aria-haspopup/expanded/controls wiring regardless of the composed content, so composition cannot drop the aria; aria-controls renders even while closed (the stable id is the wiring's resolution seam). |
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=popover] | Root wrapper around the trigger, the optional anchor, and the panel |
| [data-slot=popover-anchor] | Optional alternate popper anchor - when present the panel positions against it instead of the trigger |
| [data-slot=popover-content] | The role=dialog panel - positioning, animation, and the open state ride here |
| [data-slot=popover-header] | Title block wrapping the title and description (renders only when either is present) |
| [data-slot=popover-title] | The heading - the panel's accessible name via aria-labelledby |
| [data-slot=popover-description] | Muted copy under the title, wired to aria-describedby |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| popover-content | data-open | panel is open (the controller flips the pair at runtime) | — |
| popover-content | data-closed | panel is closed (the server-rendered state; hidden rides along) | — |
| popover-content | data-side | always - the side (initial placement, re-resolved live by popper after flip) | top · right · bottom · left |
| popover-content | data-align | always - the alignment (re-resolved live by popper) | start · center · end |
CSS variables
| Part | Variable | Description |
|---|---|---|
| popover-content | --transform-origin | the anchor-facing origin popper writes for scale-in animation |
| popover-content | --available-width | viewport space left for the panel (popper, post-flip) |
| popover-content | --available-height | viewport space left for the panel (popper, post-flip) |
| popover-content | --anchor-width | the anchor's measured width (popper) |
| popover-content | --anchor-height | the anchor's measured height (popper) |
Wiring
The Stimulus surface each element carries — declared in the component, verified against rendered DOM in CI. Bare actions fire on the element's default event.
| Element | Controller | Wiring |
|---|---|---|
| root | poetry--core--popover | registers · value open · value modal |
| root | poetry--core--popper | registers · value anchor (unless anchor?) · value side · value align · value side_offset · value align_offset · value avoid_collisions |
| trigger | poetry--core--popover | toggle on click |
| anchor_part | poetry--core--popper | target anchor |
| content | poetry--core--popper | target content |