Hover Card
stimeo--hover-card
A rich preview card opened on hover/focus with open/close delays and a hoverable bridge.
The stimeo--hover-card controller opens a non-modal preview card on hover/focus, following the Disclosure convention (aria-expanded). Unlike a tooltip it may hold interactive content (links), so the card is NOT a role="dialog" and its content must also be reachable from the trigger itself. openDelay/closeDelay prevent flicker; binding open/close on the card bridges the pointer gap, and the delayed close re-checks focus so tabbing into a link inside the card keeps it open. While open, Escape is watched at the document level and closes it regardless of focus. Focus is never stolen on open. Placement is owned by this Playground's CSS.
Keyboard
| Key | Action |
|---|---|
| Esc | Close the open card (watched document-wide, regardless of focus). |
<%# Markup for the hover_card demo.
Hover/focus on the link opens a rich supplementary card after a delay. The card
has no role="dialog" (it's purely supplementary), and its contents are assumed to
be reachable from the trigger's target too. The open/close delay, the hoverable
bridge, and Escape-to-dismiss are the library's. %>
<span class="hover-card" data-controller="stimeo--hover-card">
<a
class="hover-card__trigger"
href="#jane"
data-stimeo--hover-card-target="trigger"
aria-expanded="false"
aria-controls="hover-card-panel"
data-action="
mouseenter->stimeo--hover-card#open
mouseleave->stimeo--hover-card#close
focusin->stimeo--hover-card#open
focusout->stimeo--hover-card#close
keydown->stimeo--hover-card#onKeydown">
<%= t("components.hover_card.demo.trigger") %>
</a>
<%# The card has no role="dialog" (supplementary display, not an interrupting dialog). %>
<div
class="hover-card__panel"
id="hover-card-panel"
data-stimeo--hover-card-target="card"
data-action="
mouseenter->stimeo--hover-card#open
mouseleave->stimeo--hover-card#close"
hidden>
<p class="hover-card__name"><%= t("components.hover_card.demo.name") %></p>
<p class="hover-card__role"><%= t("components.hover_card.demo.role") %></p>
<a class="hover-card__link" href="#follow"><%= t("components.hover_card.demo.follow") %></a>
</div>
</span>
/*
* Presentation-only styles for the hover_card demo.
* The library opens/closes the card by toggling its hidden attribute. Placement
* is static — the consumer's CSS responsibility.
*/
.hover-card {
position: relative;
display: inline-block;
}
.hover-card__trigger {
color: var(--accent, var(--color-primary));
text-decoration: underline;
}
.hover-card__panel {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
z-index: 10;
width: 18rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem;
background: var(--surface, var(--surface-card));
border: 1px solid var(--border-strong);
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgb(15 23 42 / 0.12);
}
/* Setting display: flex overrides the hidden attribute's default display:none, so
re-declare display:none while closed to honor the library's hidden toggle. */
.hover-card__panel[hidden] {
display: none;
}
.hover-card__name {
font-weight: 600;
}
.hover-card__role {
font-size: 0.875rem;
color: var(--color-text-muted);
}
.hover-card__link {
margin-top: 0.5rem;
color: var(--accent, var(--color-primary));
}
// hover_card opt-in positioning demo (consumer-side JS).
//
// The core controller (stimeo--hover-card) only handles open/close, delay, and
// dismiss. Viewport-edge flip/shift is optionally connected via the opt-in
// stimeo-ui/positioning module. We watch the card's hidden and, while open, track
// the trigger's coordinates.
import { attachPositioning } from 'stimeo-ui/positioning';
document.querySelectorAll('[data-controller~="stimeo--hover-card"]').forEach((root) => {
const trigger = root.querySelector('[data-stimeo--hover-card-target="trigger"]');
const card = root.querySelector('[data-stimeo--hover-card-target="card"]');
if (!trigger || !card) return;
let detach = null;
const sync = () => {
if (!card.hidden && !detach) {
detach = attachPositioning(trigger, card, {
placement: 'bottom-start',
offset: 8,
padding: 8,
});
} else if (card.hidden && detach) {
detach();
detach = null;
card.style.position = card.style.left = card.style.top = '';
}
};
new MutationObserver(sync).observe(card, { attributes: true, attributeFilter: ['hidden'] });
sync();
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--hover-card"
Targets
| Name | Description | Attribute |
|---|---|---|
trigger
required
|
The element (e.g. a link) that opens the card on hover/focus; its aria-expanded reflects state and focus is never stolen. |
data-stimeo--hover-card-target="trigger" |
card
required
|
The non-modal popover content shown/hidden via hidden and data-state; binding open/close on it forms a hoverable bridge. |
data-stimeo--hover-card-target="card" |
Values
| Name | Description | Attribute |
|---|---|---|
openDelay
|
Milliseconds to wait before opening on hover/focus to prevent flicker (default 300). | data-stimeo--hover-card-open-delay-value |
closeDelay
|
Milliseconds to wait before closing on leave/blur (re-checks focus); prevents flicker (default 200). | data-stimeo--hover-card-close-delay-value |
Actions
| Name | Description | Action |
|---|---|---|
close
|
Schedules close after closeDelay; cancels a pending open and aborts if focus is inside the card. |
stimeo--hover-card#close |
onKeydown
|
Closes the card immediately on Escape while open. | stimeo--hover-card#onKeydown |
open
|
Opens the card after openDelay (or immediately at 0); cancels a pending close. |
stimeo--hover-card#open |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-expanded |
Trigger | "true" while the card is open, "false" when closed. |
hidden |
Card | Removed when open, present when closed. |
data-state |
Card | "open" / "closed" — an optional hook for fade transitions. |