Skip to content

Tooltip

Low-level tooltip primitive that opens a floating label on hover or focus with warm-up behavior, collision-aware positioning, and logical side support.

Updated View as Markdown

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

Type to search…

↑↓ navigate↵ selectEsc close