Hover Card
A card that reveals preview content when its trigger is hovered.
Installation
Included in poetry-ui — available as
poetry_hover_card
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 hover-card
Default
<%# Demo-parity timings (the shadcn demo passes delay 10 / closeDelay 100
so the card feels instant on a lone trigger). Real pages with inline
text links want the 600ms default back - accidental opens while
mousing across prose feel broken. %>
<%= poetry_hover_card(open_delay: 10, close_delay: 100, content_class: "flex w-64 flex-col gap-0.5") do |card| %>
<% card.with_trigger(href: "https://github.com/nextjs", class: "text-sm font-medium underline-offset-4 hover:underline") { "Hover Here" } %>
<div class="font-semibold">@nextjs</div>
<div>The React Framework - created and maintained by @vercel.</div>
<div class="mt-1 text-xs text-muted-foreground">Joined December 2021</div>
<% end %>
Custom delays
<%# Snappier timings for preview-dense surfaces. Keep open_delay >= ~300ms -
accidental opens while mousing across text feel broken. %>
<%= poetry_hover_card(open_delay: 300, close_delay: 150) do |card| %>
<% card.with_trigger(href: "https://github.com/rails", class: "text-sm font-medium underline-offset-4 hover:underline") { "@rails" } %>
Ruby on Rails - the full-stack web framework.
<% end %>
Sides
<%# The side: option decides where the panel settles around the trigger
(data-side re-resolves live on collision). Triggers stay real links -
here each one points at this section's own anchor, the demo being its
own destination. variant: :outline wears Button styling on the <a>. %>
<div class="flex flex-wrap items-center justify-center gap-2">
<% %i[left top bottom right].each do |side| %>
<%= poetry_hover_card(side: side, open_delay: 100, close_delay: 100) do |card| %>
<% card.with_trigger(href: "#sides", variant: :outline, class: "capitalize") { side.to_s } %>
<div class="flex flex-col gap-1">
<h4 class="font-medium">Hover Card</h4>
<p>This hover card appears on the <%= side %> side of the trigger.</p>
</div>
<% end %>
<% end %>
</div>
API
Poetry::Ui::HoverCard::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 |
|---|---|---|---|
| close_delay: | Integer | defaults to 300 |
Close-grace window in ms over the trigger+content pair. |
| content_class: | String | The panel's class merge seam - e.g. content_class: \"w-80\" widens the card. | |
| defer: | String | Defer the card body to a lazy turbo-frame. The panel is hidden until hover, so the fetch fires on first open for free; the component block (if any) becomes the frame's placeholder. | |
| open: | Boolean | defaults to false |
Renders the card already open on page load. |
| open_delay: | Integer | defaults to 600 |
Hover-intent delay in ms before the card opens. |
Slots
| Writer | Description |
|---|---|
| with_trigger | The enriched LINK: a real navigable <a> - THE no-JS fallback. tag: passthrough exists but change it knowingly (an <a> is the contract's fallback story). NO aria-haspopup/expanded/describedby - the card is invisible to the accessibility tree on purpose. Built as a lazy anatomy part (rendered at render time, not at with_trigger time). variant:/size: route through Button::Component - Button's href-implies-anchor keeps the trigger a REAL <a> wearing button styling, so the reachable-elsewhere contract holds. |
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=hover-card] | Root wrapper around the trigger link and the panel |
| [data-slot=hover-card-trigger] | The enriched link itself - simultaneously the no-JS fallback, the touch path, and the keyboard path |
| [data-slot=hover-card-content] | The role-less preview panel (invisible to AT on purpose) - positioning, animation, and the open state ride here |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| hover-card-trigger | data-popup-open | bare while the card is open; absent while closed (absence IS the closed state) | — |
| hover-card-content | data-open | card is open (the controller flips the pair at runtime) | — |
| hover-card-content | data-closed | card is closed (the server-rendered state; hidden rides along) | — |
| hover-card-content | data-side | always - the side (initial placement, re-resolved live by popper after flip) | top · right · bottom · left |
| hover-card-content | data-align | always - the alignment (re-resolved live by popper) | start · center · end |
CSS variables
| Part | Variable | Description |
|---|---|---|
| hover-card-content | --transform-origin | the anchor-facing origin popper writes for scale-in animation |
| hover-card-content | --available-width | viewport space left for the panel (popper, post-flip) |
| hover-card-content | --available-height | viewport space left for the panel (popper, post-flip) |
| hover-card-content | --anchor-width | the anchor's measured width (popper) |
| hover-card-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--hover-card | registers · value open · value open_delay · value close_delay |
| root | poetry--core--popper | registers · value side · value align · value side_offset · value align_offset · value avoid_collisions |
| trigger | poetry--core--hover-card | pointerEnter on pointerenter · pointerLeave on pointerleave · focusOpen on focus · blurClose on blur · touchGuard on touchstart |
| trigger | poetry--core--popper | target anchor |
| content | poetry--core--popper | target content |