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.