---
name: tooltip
type: core
domain: overlays
requires: [loke-ui]
description: >
  Hover/focus tooltips. TooltipProvider, Tooltip, TooltipTrigger, TooltipPortal,
  TooltipContent, TooltipArrow. Requires TooltipProvider ancestor. Default delay 700ms,
  skip-delay 300ms. data-state: closed | delayed-open | instant-open.
  CSS vars: --loke-tooltip-content-available-height, -width, -transform-origin,
  --loke-tooltip-trigger-height, -width.
---

# Tooltip

## Setup

Minimum working tooltip. `TooltipProvider` must wrap all `Tooltip` usage — place it near the root of your app or layout.

```tsx
import {
  Tooltip,
  TooltipArrow,
  TooltipContent,
  TooltipPortal,
  TooltipProvider,
  TooltipTrigger,
} from "@loke/ui/tooltip";

function Example() {
  return (
    <TooltipProvider>
      <Tooltip>
        <TooltipTrigger>Hover me</TooltipTrigger>
        <TooltipPortal>
          <TooltipContent>
            Save document
            <TooltipArrow />
          </TooltipContent>
        </TooltipPortal>
      </Tooltip>
    </TooltipProvider>
  );
}
```

`TooltipContent` renders inside a hidden `role="tooltip"` element for screen readers via `aria-describedby` on the trigger. The visible content and the hidden tooltip element both receive the same text (or `aria-label` if provided).

Default open delay: 700ms. Skip-delay window: 300ms (moving between triggers skips the delay).

## Core Patterns

### Custom delay

Override delay at the provider level or per-tooltip.

```tsx
import {
  Tooltip,
  TooltipContent,
  TooltipPortal,
  TooltipProvider,
  TooltipTrigger,
} from "@loke/ui/tooltip";

// Provider-level: all tooltips use 400ms
function App() {
  return (
    <TooltipProvider delayDuration={400} skipDelayDuration={150}>
      <MyLayout />
    </TooltipProvider>
  );
}

// Per-tooltip override: this one is instant
function InstantTooltip() {
  return (
    <TooltipProvider>
      <Tooltip delayDuration={0}>
        <TooltipTrigger>Icon button</TooltipTrigger>
        <TooltipPortal>
          <TooltipContent>Delete row</TooltipContent>
        </TooltipPortal>
      </Tooltip>
    </TooltipProvider>
  );
}
```

`delayDuration={0}` opens the tooltip immediately on hover, which sets `data-state="instant-open"` rather than `"delayed-open"`.

### Simple text tooltip with aria-label

When the trigger already has visible text that differs from the tooltip, use `aria-label` on `TooltipContent` to provide a distinct accessible description without duplicating visible content.

```tsx
import {
  Tooltip,
  TooltipContent,
  TooltipPortal,
  TooltipProvider,
  TooltipTrigger,
} from "@loke/ui/tooltip";

function KeyboardShortcutHint() {
  return (
    <TooltipProvider>
      <Tooltip>
        <TooltipTrigger>Save</TooltipTrigger>
        <TooltipPortal>
          <TooltipContent aria-label="Save document (Ctrl+S)">
            Ctrl+S
          </TooltipContent>
        </TooltipPortal>
      </Tooltip>
    </TooltipProvider>
  );
}
```

The hidden `role="tooltip"` element renders `aria-label` text ("Save document (Ctrl+S)") while the visible content shows "Ctrl+S".

### Hoverable content (links, buttons inside tooltip)

By default, `TooltipProvider` uses `disableHoverableContent={false}`, meaning the pointer can move from the trigger to the tooltip content without it closing. A convex hull grace area is computed between trigger and content.

To allow interactive content inside a tooltip-like overlay, use `Popover` instead — tooltips close on click and do not support `role="dialog"` interaction patterns. For informational hoverable content only:

```tsx
import {
  Tooltip,
  TooltipContent,
  TooltipPortal,
  TooltipProvider,
  TooltipTrigger,
} from "@loke/ui/tooltip";

function HoverableTooltip() {
  return (
    <TooltipProvider disableHoverableContent={false}>
      <Tooltip>
        <TooltipTrigger>Status</TooltipTrigger>
        <TooltipPortal>
          <TooltipContent>
            Last synced 2 minutes ago
          </TooltipContent>
        </TooltipPortal>
      </Tooltip>
    </TooltipProvider>
  );
}
```

Set `disableHoverableContent={true}` on `TooltipProvider` or `Tooltip` if you want the tooltip to close as soon as the pointer leaves the trigger.

## Common Mistakes

### Missing TooltipProvider wrapper

`Tooltip` reads delay timers and skip-delay state from `TooltipProviderContext`. Without a `TooltipProvider` ancestor, the context hook throws at runtime.

```tsx
// Wrong — no provider, throws in runtime
function App() {
  return (
    <Tooltip>
      <TooltipTrigger>Hover me</TooltipTrigger>
      <TooltipContent>Info</TooltipContent>
    </Tooltip>
  );
}

// Correct
function App() {
  return (
    <TooltipProvider>
      <Tooltip>
        <TooltipTrigger>Hover me</TooltipTrigger>
        <TooltipPortal>
          <TooltipContent>Info</TooltipContent>
        </TooltipPortal>
      </Tooltip>
    </TooltipProvider>
  );
}
```

One `TooltipProvider` at the app root is sufficient. You do not need a provider per tooltip.

Source: `src/components/tooltip/tooltip.tsx` — `useTooltipProviderContext` requires the context.

### Using Tooltip for interactive content

`TooltipTrigger` closes the tooltip on `onClick` and `onPointerDown`. Interactive content (buttons, links, forms) inside a tooltip-style overlay belongs in a `Popover`, which uses `role="dialog"`, supports focus trapping, and does not close on pointer-down.

Source: `src/components/tooltip/tooltip.tsx` — `onClick` and `onPointerDown` call `context.onClose`.

### Missing TooltipPortal around TooltipContent

Without `TooltipPortal`, `TooltipContent` renders inline in the DOM. This causes z-index stacking failures and clipping by `overflow: hidden` ancestors — commonly seen inside table cells, cards, or modals.

```tsx
// Wrong — may be clipped or appear behind content
<Tooltip>
  <TooltipTrigger>Help</TooltipTrigger>
  <TooltipContent>Explanation</TooltipContent>
</Tooltip>

// Correct
<Tooltip>
  <TooltipTrigger>Help</TooltipTrigger>
  <TooltipPortal>
    <TooltipContent>Explanation</TooltipContent>
  </TooltipPortal>
</Tooltip>
```

Source: `src/components/tooltip/tooltip.tsx`.

### Not understanding data-state delayed-open vs instant-open

`TooltipTrigger` has three `data-state` values: `"closed"`, `"delayed-open"`, and `"instant-open"`. CSS selectors targeting only `open` miss both open states. CSS selectors targeting `data-state="open"` match nothing — there is no `"open"` value.

```css
/* Wrong — no data-state="open" on TooltipTrigger */
[data-state="open"] { opacity: 1; }

/* Correct — both open states */
[data-state="delayed-open"],
[data-state="instant-open"] { opacity: 1; }
```

`instant-open` fires when the skip-delay window is active (user moved quickly from another tooltip). `delayed-open` fires after the normal delay expires.

Source: `src/components/tooltip/tooltip.tsx` — `stateAttribute` computation in `Tooltip`.

## Cross-references

- See also: [popover](../popover/SKILL.md) — for interactive floating content (forms, buttons)
- See also: [choosing-the-right-component](../choosing-the-right-component/SKILL.md) — Tooltip vs Popover decision
- See also: [overlay-infrastructure](../overlay-infrastructure/SKILL.md) — Popper, Presence, DismissableLayer internals
