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.
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
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.
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.
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.
<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:
<!-- 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.
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
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
Escapedismisses the tooltip. - Moving the pointer from the trigger to the content keeps the tooltip open.
- Disabled triggers do not open tooltips.
aria-describedbyis set on the trigger while open, pointing to the content.role="tooltip"is set on the content.- The arrow element receives
aria-hidden="true".