Hover Card
Opens a positioned popup when the user hovers or focuses a trigger element. HoverCard handles warm-up behavior between nearby instances, collision-aware floating positioning, portal rendering, and accessible dismissal.
import { HoverCard } from "@areia/slots";
document.querySelector("#root")!.innerHTML = `
<div data-slot="hover-card">
<button data-slot="hover-card-trigger" class="win95-button">Hover me</button>
<div data-slot="hover-card-content" class="win95-menu">
Preview content
<div data-slot="hover-card-arrow"></div>
</div>
</div>
`;
const root = document.querySelector(
'[data-slot="hover-card"]',
)!;
const controller = HoverCard.createHoverCard(root, {
delay: 700,
side: "top",
});Import
import { HoverCard } from "@areia/slots";Usage
Add data-slot="hover-card" to a container element. Place a trigger and content element inside with the matching sub-slots. Call HoverCard.createHoverCard(root) to initialize the controller.
import { HoverCard } from "@areia/slots";
// Auto-bind a single root
const root = document.querySelector('[data-slot="hover-card"]');
const controller = HoverCard.createHoverCard(root, {
delay: 700,
closeDelay: 300,
side: "top",
align: "center",
});
// Auto-bind all unbound roots in scope
const controllers = HoverCard.create();Warm-Up Behavior
By default, after one hover-card closes, hovering another hover-card within skipDelayDuration (default 300ms) opens it without the normal delay. This creates a seamless transition between nearby hover-cards, similar to a menu bar.
Set skipDelayDuration: 0 to disable warm-up and always use the full delay.
Expected Markup
The controller expects a root element with data-slot="hover-card" and the following sub-slots.
<div data-slot="hover-card">
<button data-slot="hover-card-trigger">Hover me</button>
<div data-slot="hover-card-content">
Preview content
<div data-slot="hover-card-arrow"></div>
</div>
</div>While open, the content is wrapped in generated elements:
<!-- Portal container in document.body -->
<div
data-slot="hover-card-positioner"
style="position: absolute; top: 0; left: 0; transform: translate3d(...);"
>
<div data-slot="hover-card-content">...</div>
</div>Data Slots
| Slot | Required | Description |
|---|---|---|
hover-card |
Yes | Root container. |
hover-card-trigger |
Yes | The element that opens the hover-card on hover/focus. |
hover-card-content |
Yes | The popup content. Portal-rendered while open. |
hover-card-arrow |
No | Optional decorative arrow. Positioned by the controller. |
Generated Attributes
The controller writes the following attributes on initialization and updates them as state changes:
| Attribute | Element(s) | Description |
|---|---|---|
data-open |
Root | Present when the hover-card is open. |
data-closed |
Root | Present when the hover-card is closed. |
data-side |
Content | The resolved placement side ("top", "right", "bottom", "left"). |
data-state |
Content | "open" or "closed". |
Positioning Attributes
The controller resolves positioning values with this precedence: JS options > content data-* attributes > root data-* attributes > defaults.
| Data Attribute | Type | Description |
|---|---|---|
data-side |
"top" | "right" | "bottom" | "left" |
Preferred placement side. |
data-align |
"start" | "center" | "end" |
Preferred alignment. |
data-side-offset |
number |
Distance from the trigger. |
data-align-offset |
number |
Offset from the alignment edge. |
data-avoid-collisions |
boolean |
Enable collision detection. |
data-collision-padding |
number |
Viewport edge padding. |
data-portal |
boolean |
Portal content to document.body. |
Events
Outbound
| Event | Detail | Description |
|---|---|---|
hover-card:change |
{ open: boolean } |
Fired on the root when the open state changes. |
Inbound
| Event | Detail | Description |
|---|---|---|
hover-card:set |
{ open: boolean } |
Dispatch on the root to set the open state programmatically. |
API Reference
Options
| Option | Type | Default | Description |
|---|---|---|---|
defaultOpen |
boolean |
false |
Initial open state (uncontrolled mode only). |
open |
boolean |
— | Controlled open state. Internal interactions do not mutate when set. |
delay |
number |
700 |
Delay before opening on hover or keyboard focus, in milliseconds. |
skipDelayDuration |
number |
300 |
Duration to skip the open delay after another hover-card closes. Set 0 to disable warm-up. |
closeDelay |
number |
300 |
Delay before closing after the pointer leaves or focus is lost, in milliseconds. |
side |
"top" | "right" | "bottom" | "left" |
"bottom" |
Preferred side relative to the trigger. May flip to avoid collisions. |
align |
"start" | "center" | "end" |
"center" |
Preferred alignment along the selected side. |
sideOffset |
number |
4 |
Distance from the trigger in pixels. |
alignOffset |
number |
0 |
Offset from the aligned edge in pixels. |
avoidCollisions |
boolean |
true |
When true, flips or shifts content to stay inside the viewport. |
collisionPadding |
number |
8 |
Viewport edge padding used for collision handling. |
portal |
boolean |
true |
Portal content to document.body while open. Set false to keep content inline. |
closeOnClickOutside |
boolean |
true |
Close when clicking outside the hover-card. |
closeOnEscape |
boolean |
true |
Close when pressing Escape. |
onOpenChange |
(open: boolean) => void |
— | Called when the open state changes. |
onPortalMounted |
(container: HTMLElement) => void |
— | After portaled content mounts on open. |
Controller
| Member | Type | Description |
|---|---|---|
isOpen |
boolean (getter) |
Current open state. |
open() |
() => void |
Opens the hover-card (request in controlled mode). |
close() |
() => void |
Closes the hover-card (request in controlled mode). |
toggle() |
() => void |
Toggles the hover-card (request in controlled mode). |
setOpen(open) |
(open: boolean) => void |
Forces open state. Works in both controlled and uncontrolled modes. |
destroy() |
() => void |
Removes all event listeners and cleans up the controller. |
Controller
The controller is returned by HoverCard.createHoverCard() and provides imperative control over the hover-card’s state.
import { HoverCard } from "@areia/slots";
const root = document.querySelector('[data-slot="hover-card"]');
const controller = HoverCard.createHoverCard(root, {
delay: 700,
side: "top",
onOpenChange: (open) => {
console.log("hover-card open:", open);
},
});
// Open immediately
controller.open();
// Check current state
console.log(controller.isOpen);
// Close after a delay
setTimeout(() => controller.close(), 3000);
// Force state regardless of mode
controller.setOpen(false);
// Tear down
controller.destroy();Listening to Events
const root = document.querySelector('[data-slot="hover-card"]');
root.addEventListener("hover-card:change", (event) => {
const { open } = event.detail;
console.log("hover-card changed:", open);
});Controlled Mode
When the open option is provided, the hover-card operates in controlled mode. The controller dispatches hover-card:change events instead of mutating state internally — the host application should call controller.setOpen() in response.
import { HoverCard } from "@areia/slots";
let isOpen = false;
const root = document.querySelector('[data-slot="hover-card"]');
const controller = HoverCard.createHoverCard(root, {
open: isOpen,
onOpenChange: (next) => {
isOpen = next;
controller.setOpen(next);
},
});Accessibility
- Opens on pointer hover for mouse/pen input.
- Opens on keyboard focus.
- Touch hover is ignored; touch devices use focus behavior.
- Pressing
Escapedismisses the hover-card. - Moving from the trigger to the content keeps it open.
- Disabled triggers do not open hover-cards.
aria-describedbyis set on the trigger while open.role="tooltip"is set on the content.