Hover Card
A rich preview of what's behind a link, shown on hover or keyboard focus. Same top-layer mechanics as tooltip, with a popover-styled panel and longer delays.
Installation
bin/rails g shadcnrb:component hover_card
Example
The block is the panel; h.trigger marks what it's
attached to — the same shape as
tooltip.
You write the trigger yourself, so a real sui.link_to
keeps its path, Turbo and CSRF (upstream's trigger is an
<a> too). Opens after 600ms, closes 300ms after the
pointer leaves, which is enough grace to move onto the panel and click
something in it.
<%= sui.hover_card content: { class: "w-80" } do |h| %> <%= h.trigger { sui.link_to "@shadcn", "https://github.com/shadcn" } %> <div class="flex gap-4"> <%= sui.avatar do |a| %> <%= a.fallback "SC" %> <% end %> <div class="space-y-1"> <p class="text-sm font-semibold">@shadcn</p> <p class="text-sm">The React Framework – created and maintained by @vercel.</p> <p class="text-xs text-muted-foreground">Joined December 2021</p> </div> </div> <% end %>
Attaching to any helper
Anything can be the trigger — hover is bound on the wrapper, so the element itself is left untouched.
<%= sui.hover_card align: :start do |h| %> <%= h.trigger { sui.badge "Pro", variant: :secondary } %> <p class="text-sm font-medium">Pro plan</p> <p class="text-sm text-muted-foreground">Unlimited projects, 10 seats, priority support.</p> <% end %> <%= sui.hover_card side: :right do |h| %> <%= h.trigger do %> <%= sui.avatar do |a| %> <%= a.fallback "GT" %> <% end %> <% end %> <p class="text-sm font-medium">Gogs T.</p> <p class="text-sm text-muted-foreground">Last seen 4 minutes ago.</p> <% end %>
Sides and alignment
side: defaults to :bottom,
align: to :center. No collision
detection — the panel stays where you put it.
<%= sui.hover_card side: :top do |h| %> <%= h.trigger { sui.button "Above", variant: :outline } %> <p class="text-sm">Anchored to the top of the trigger.</p> <% end %> <%= sui.hover_card side: :right, align: :start do |h| %> <%= h.trigger { sui.button "Right, start", variant: :outline } %> <p class="text-sm">Anchored right, top edges flush.</p> <% end %>
Delays
Upstream hangs delay / closeDelay off
the trigger; here they live on the wrapper so one call configures the pair.
<%= sui.hover_card delay: 0, close_delay: 600 do |h| %> <%= h.trigger { sui.button "Instant", variant: :outline } %> <p class="text-sm">Opens immediately, lingers for 600ms.</p> <% end %>
Lazy-loaded content
Pass src: and the panel renders a Turbo Frame that
fetches when the card first opens — the GitHub-style profile preview.
The block body is the loading state (blank gets a default
"Loading..." placeholder). Content is fetched once and reused on
later opens, and the panel re-positions itself when the loaded
content changes the panel's size. The endpoint wraps its response in
a <turbo-frame> tag matching the request's
Turbo-Frame header.
<%= sui.hover_card src: lazy_hover_card_content_path, delay: 100, content: { class: "w-72" } do |h| %> <%= h.trigger { sui.link_to "@gogo", "#", variant: :link } %> <div class="flex gap-3" aria-hidden="true"> <%= sui.skeleton class: "size-10 shrink-0 rounded-full" %> <div class="flex-1 space-y-2 py-1"> <%= sui.skeleton class: "h-3 w-24" %> <%= sui.skeleton class: "h-3" %> <%= sui.skeleton class: "h-3 w-2/3" %> </div> </div> <% end %>
Trigger kwarg
For a one-off "element with a preview card", skip the wrapper block:
pass hover_card: to sui.button (or
sui.link_to) with the card's options —
loading: sets the loading state, since there's no
block to put it in.
<%= sui.button "Hover for profile", variant: :outline, hover_card: { src: lazy_hover_card_content_path, delay: 100, loading: sui.skeleton(class: "h-16"), content: { class: "w-72" } } %>
Reload on every open
reload: true re-fetches instead of caching the first
response, and the loading state is restored on close — same options as
sui.dialog.
<%= sui.hover_card src: lazy_hover_card_content_path, reload: true, delay: 100, content: { class: "w-72" } do |h| %> <%= h.trigger { sui.button "Fresh every open", variant: :outline } %> <%= sui.skeleton class: "h-16" %> <% end %>
API
| Method | Args | Default | Description |
|---|---|---|---|
sui.hover_card | side:, align:, delay:, close_delay:, src:, reload:, content:, **opts | :bottom / :center / 600 / 300 / nil / false | Wrapper; the block body is the panel — a w-64 popover surface. Mounts the shared shadcnrb--anchored--component controller and owns the hover/focus wiring. **opts land on the wrapper, content: on the panel. With src: the body becomes the loading state for a lazily fetched Turbo Frame; reload: true re-fetches on every open. |
h.trigger | &block | — | Marks which part of the block is the trigger; everything else is the panel. Your markup, verbatim — the component renders no link or button of its own, which is why it has no dependencies. Hoisted to the top of the wrapper, so its position in the block doesn't matter. |
Divergences
Delays live on the wrapper rather than the trigger, and there's no
HoverCardContent part — the block body is the panel.
Positioning is @floating-ui/dom, vendored via
importmap (no npm), and the panel is a native
popover="manual" element in the top layer (nothing
can clip it) instead of a portal.
sideOffset / alignOffset are baked
in at 4px.