Skip to content

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

SlotRequiredDescription
hover-cardYesRoot container.
hover-card-triggerYesThe element that opens the hover-card on hover/focus.
hover-card-contentYesThe popup content. Portal-rendered while open.
hover-card-arrowNoOptional decorative arrow. Positioned by the controller.

Generated Attributes

The controller writes the following attributes on initialization and updates them as state changes:

AttributeElement(s)Description
data-openRootPresent when the hover-card is open.
data-closedRootPresent when the hover-card is closed.
data-sideContentThe resolved placement side ("top", "right", "bottom", "left").
data-stateContent"open" or "closed".

Positioning Attributes

The controller resolves positioning values with this precedence: JS options > content data-* attributes > root data-* attributes > defaults.

Data AttributeTypeDescription
data-side"top" | "right" | "bottom" | "left"Preferred placement side.
data-align"start" | "center" | "end"Preferred alignment.
data-side-offsetnumberDistance from the trigger.
data-align-offsetnumberOffset from the alignment edge.
data-avoid-collisionsbooleanEnable collision detection.
data-collision-paddingnumberViewport edge padding.
data-portalbooleanPortal content to document.body.

Events

Outbound

EventDetailDescription
hover-card:change{ open: boolean }Fired on the root when the open state changes.

Inbound

EventDetailDescription
hover-card:set{ open: boolean }Dispatch on the root to set the open state programmatically.

API Reference

Options

OptionTypeDefaultDescription
defaultOpenbooleanfalseInitial open state (uncontrolled mode only).
openbooleanControlled open state. Internal interactions do not mutate when set.
delaynumber700Delay before opening on hover or keyboard focus, in milliseconds.
skipDelayDurationnumber300Duration to skip the open delay after another hover-card closes. Set 0 to disable warm-up.
closeDelaynumber300Delay 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.
sideOffsetnumber4Distance from the trigger in pixels.
alignOffsetnumber0Offset from the aligned edge in pixels.
avoidCollisionsbooleantrueWhen true, flips or shifts content to stay inside the viewport.
collisionPaddingnumber8Viewport edge padding used for collision handling.
portalbooleantruePortal content to document.body while open. Set false to keep content inline.
closeOnClickOutsidebooleantrueClose when clicking outside the hover-card.
closeOnEscapebooleantrueClose when pressing Escape.
onOpenChange(open: boolean) => voidCalled when the open state changes.
onPortalMounted(container: HTMLElement) => voidAfter portaled content mounts on open.

Controller

MemberTypeDescription
isOpenboolean (getter)Current open state.
open()() => voidOpens the hover-card (request in controlled mode).
close()() => voidCloses the hover-card (request in controlled mode).
toggle()() => voidToggles the hover-card (request in controlled mode).
setOpen(open)(open: boolean) => voidForces open state. Works in both controlled and uncontrolled modes.
destroy()() => voidRemoves 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.