@loke/ui
Version:
242 lines (193 loc) • 7.29 kB
Markdown
---
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