Skip to content

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 SideLTRRTL
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

SlotRequiredDescription
tooltipYesRoot container.
tooltip-triggerYesThe element that opens the tooltip on hover or focus.
tooltip-contentYesThe floating content. Portal-rendered while open.
tooltip-arrowNoOptional 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:

AttributeElement(s)Description
data-openRoot, content, positioner, arrowPresent when the tooltip is open.
data-closedRoot, content, positioner, arrowPresent when the tooltip is closed.
data-stateRoot, content"open" or "closed".
data-sideContent, positioner, arrowThe resolved placement side ("top", "right", "bottom", "left").
data-alignContent, positioner, arrowThe resolved alignment ("start", "center", "end").
data-instantRoot, content, positioner, arrowSet 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 AttributeTypeDescription
data-side"top" | "right" | "bottom" | "left" | "inline-start" | "inline-end"Preferred placement side.
data-align"start" | "center" | "end"Preferred alignment.
data-side-offsetnumberDistance from the trigger.
data-align-offsetnumberOffset from the alignment edge.
data-avoid-collisionsbooleanEnable collision detection.
data-collision-paddingnumberViewport edge padding.
data-delaynumberDelay before opening, in milliseconds.
data-skip-delay-durationnumberWarm-up window duration, in milliseconds.

CSS Custom Properties

PropertyElementDescription
--transform-originpositionerCSS transform-origin value computed from the resolved placement. Useful for scale/fade animations.

Events

Outbound

EventDetailDescription
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

EventDetailDescription
tooltip:set{ open: boolean }Dispatch on the root to set the open state programmatically.

API Reference

Options

OptionTypeDefaultDescription
delaynumber300Delay before opening on hover or keyboard focus, in milliseconds.
skipDelayDurationnumber300Duration 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.
sideOffsetnumber4Distance from the trigger in pixels.
alignOffsetnumber0Offset from the aligned edge in pixels.
avoidCollisionsbooleantrueWhen true, flips or shifts content to stay inside the viewport.
collisionPaddingnumber8Viewport edge padding used for collision handling.
portalbooleantruePortal content to document.body while open. Set false to keep content inline.
onOpenChange(open: boolean) => voidCalled when the open state changes.

Controller

MemberTypeDescription
isOpenboolean (getter)Current open state.
show()() => voidOpens the tooltip immediately. Respects the disabled state of the trigger.
hide()() => voidCloses the tooltip immediately.
destroy()() => voidRemoves 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".