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