---
title: Tooltip
description: "Low-level tooltip primitive that opens a floating label on hover or focus with warm-up behavior, collision-aware positioning, and logical side support."
order: 23
tags: [primitives]
---

# Tooltip

Opens a positioned floating element when the user hovers or focuses a trigger. `Tooltip` handles warm-up behavior between nearby instances, collision-aware floating positioning, logical side resolution for LTR/RTL writing directions, portal rendering, and accessible dismissal.

```ts
import { Tooltip } from "@areia/slots";

document.querySelector("#root")!.innerHTML = `
  <div data-slot="tooltip">
    <button data-slot="tooltip-trigger" class="win95-button">Hover me</button>
    <div data-slot="tooltip-content" class="border border-black bg-[#ffffcc] px-2 py-1 text-xs shadow-[2px_2px_0_#000]">
      Tooltip text
      <div data-slot="tooltip-arrow"></div>
    </div>
  </div>
`;

const root = document.querySelector('[data-slot="tooltip"]')!;
const controller = Tooltip.createTooltip(root, {
  delay: 300,
  side: "top",
});
```

## Import

```ts
import { Tooltip } from "@areia/slots";
```

## Usage

Add `data-slot="tooltip"` to a container element. Place a trigger and content element inside with the matching sub-slots. Call `Tooltip.createTooltip(root)` to initialize the controller.

```ts
import { Tooltip } from "@areia/slots";

// Auto-bind a single root
const root = document.querySelector('[data-slot="tooltip"]');
const controller = Tooltip.createTooltip(root, {
  delay: 300,
  side: "top",
  align: "center",
});

// Auto-bind all unbound roots in scope
const controllers = Tooltip.create();
```

## Warm-Up Behavior

By default, after one tooltip closes, hovering another tooltip within `skipDelayDuration` (default `300ms`) opens it without the normal `delay`. This creates a seamless transition between nearby tooltips, similar to hover cards in a menu bar.

Warm-up is global: closing one tooltip warms the handoff window, and any other tooltip triggered within that window skips its delay.

Set `skipDelayDuration: 0` to disable warm-up and always use the full delay.

```ts
Tooltip.createTooltip(root, {
  delay: 500, // 500ms delay on first hover
  skipDelayDuration: 200, // 200ms window where adjacent tooltips open instantly
});
```

## Logical Sides

`Tooltip` supports `"inline-start"` and `"inline-end"` as `side` values. These resolve to a physical side based on the writing direction:

| Logical Side   | LTR       | RTL       |
| -------------- | --------- | --------- |
| `inline-start` | `"left"`  | `"right"` |
| `inline-end`   | `"right"` | `"left"`  |

The direction is determined by the `dir` attribute on the trigger or root, the computed `direction` CSS property, or the `dir` attribute on `<html>`.

## Expected Markup

The controller expects a root element with `data-slot="tooltip"` and the following sub-slots.

```html
<div data-slot="tooltip">
  <button data-slot="tooltip-trigger">Hover me</button>
  <div data-slot="tooltip-content">
    Tooltip text
    <div data-slot="tooltip-arrow"></div>
  </div>
</div>
```

While open, the content is wrapped in generated elements:

```html
<!-- Portal container in document.body -->
<div
  data-slot="tooltip-positioner"
  style="position: absolute; top: 0; left: 0; transform: translate3d(...);"
>
  <div data-slot="tooltip-content">...</div>
</div>
```

### Data Slots

| Slot              | Required | Description                                                                                |
| ----------------- | -------- | ------------------------------------------------------------------------------------------ |
| `tooltip`         | Yes      | Root container.                                                                            |
| `tooltip-trigger` | Yes      | The element that opens the tooltip on hover or focus.                                      |
| `tooltip-content` | Yes      | The floating content. Portal-rendered while open.                                          |
| `tooltip-arrow`   | No       | Optional decorative arrow. Positioned by the controller. Must be inside `tooltip-content`. |

### Generated Attributes

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

| Attribute      | Element(s)                       | Description                                                                                  |
| -------------- | -------------------------------- | -------------------------------------------------------------------------------------------- |
| `data-open`    | Root, content, positioner, arrow | Present when the tooltip is open.                                                            |
| `data-closed`  | Root, content, positioner, arrow | Present when the tooltip is closed.                                                          |
| `data-state`   | Root, content                    | `"open"` or `"closed"`.                                                                      |
| `data-side`    | Content, positioner, arrow       | The resolved placement side (`"top"`, `"right"`, `"bottom"`, `"left"`).                      |
| `data-align`   | Content, positioner, arrow       | The resolved alignment (`"start"`, `"center"`, `"end"`).                                     |
| `data-instant` | Root, content, positioner, arrow | Set to `"delay"`, `"focus"`, or `"dismiss"` during fast transitions for CSS animation hooks. |

### Positioning Attributes

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

| Data Attribute             | Type                                                                       | Description                               |
| -------------------------- | -------------------------------------------------------------------------- | ----------------------------------------- |
| `data-side`                | `"top" \| "right" \| "bottom" \| "left" \| "inline-start" \| "inline-end"` | 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-delay`               | `number`                                                                   | Delay before opening, in milliseconds.    |
| `data-skip-delay-duration` | `number`                                                                   | Warm-up window duration, in milliseconds. |

### CSS Custom Properties

| Property             | Element    | Description                                                                                        |
| -------------------- | ---------- | -------------------------------------------------------------------------------------------------- |
| `--transform-origin` | positioner | CSS transform-origin value computed from the resolved placement. Useful for scale/fade animations. |

## Events

### Outbound

| Event            | Detail                                                                          | Description                                                                                                                                           |
| ---------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tooltip:change` | `{ open: boolean; trigger: HTMLElement; content: HTMLElement; reason: string }` | Fired on the root when the open state changes. The `reason` indicates what caused the change (`"pointer"`, `"focus"`, `"blur"`, `"escape"`, `"api"`). |

### Inbound

| Event         | Detail              | Description                                                  |
| ------------- | ------------------- | ------------------------------------------------------------ |
| `tooltip:set` | `{ open: boolean }` | Dispatch on the root to set the open state programmatically. |

## API Reference

### Options

| Option              | Type                                                                       | Default    | Description                                                                               |
| ------------------- | -------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------- |
| `delay`             | `number`                                                                   | `300`      | Delay before opening on hover or keyboard focus, in milliseconds.                         |
| `skipDelayDuration` | `number`                                                                   | `300`      | Duration to skip the open delay after another tooltip closes. Set `0` to disable warm-up. |
| `side`              | `"top" \| "right" \| "bottom" \| "left" \| "inline-start" \| "inline-end"` | `"top"`    | 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.         |
| `onOpenChange`      | `(open: boolean) => void`                                                  | —          | Called when the open state changes.                                                       |

### Controller

| Member      | Type               | Description                                                                |
| ----------- | ------------------ | -------------------------------------------------------------------------- |
| `isOpen`    | `boolean` (getter) | Current open state.                                                        |
| `show()`    | `() => void`       | Opens the tooltip immediately. Respects the disabled state of the trigger. |
| `hide()`    | `() => void`       | Closes the tooltip immediately.                                            |
| `destroy()` | `() => void`       | Removes all event listeners and cleans up the controller.                  |

## Controller

The controller is returned by `Tooltip.createTooltip()` and provides imperative control over the tooltip's state.

```ts
import { Tooltip } from "@areia/slots";

const root = document.querySelector('[data-slot="tooltip"]');
const controller = Tooltip.createTooltip(root, {
  delay: 300,
  side: "top",
  onOpenChange: (open) => {
    console.log("tooltip open:", open);
  },
});

// Open immediately
controller.show();

// Check current state
console.log(controller.isOpen);

// Close
controller.hide();

// Tear down
controller.destroy();
```

### Listening to Events

```ts
const root = document.querySelector('[data-slot="tooltip"]');

root.addEventListener("tooltip:change", (event) => {
  const { open, reason } = event.detail;
  console.log("tooltip changed:", open, reason);
});
```

### Accessibility

- Opens on pointer hover for mouse/pen input.
- Opens on keyboard focus.
- Touch hover is ignored; touch devices use focus-only behavior.
- Pressing `Escape` dismisses the tooltip.
- Moving the pointer from the trigger to the content keeps the tooltip open.
- Disabled triggers do not open tooltips.
- `aria-describedby` is set on the trigger while open, pointing to the content.
- `role="tooltip"` is set on the content.
- The arrow element receives `aria-hidden="true"`.
