Skip to content

Hover Card

Low-level hover card primitive that opens a positioned popup on hover or focus with warm-up and collision detection.

Updated View as Markdown

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 Escape dismisses the hover-card.
  • Moving from the trigger to the content keeps it open.
  • Disabled triggers do not open hover-cards.
  • aria-describedby is set on the trigger while open.
  • role="tooltip" is set on the content.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close