---
title: Hover Card
description: Low-level hover card primitive that opens a positioned popup on hover or focus with warm-up and collision detection.
order: 11
tags: [primitives]
---

# 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.

```ts
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

```ts
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.

```ts
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.

```html
<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:

```html
<!-- 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.

```ts
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

```ts
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.

```ts
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.
