Loading…
Loading…
A small text popup that appears on hover or focus to describe an element.
The Tooltip is a small text popup that appears on hover or focus to describe an element. It provides supplementary information — a label for an icon-only button, a definition for a technical term, or a preview of a truncated value. Tooltips are the lightest-weight informational overlay in your toolkit.
Tooltips serve two distinct purposes, and conflating them causes problems. Naming tooltips provide the accessible name for elements that don't have visible text (icon buttons, abbreviated labels). Supplementary tooltips provide additional context for elements that already have a visible label ("Last updated 3 hours ago" on a timestamp).
The critical constraint of tooltips is discoverability: there is no visual indicator that a tooltip exists. Users discover tooltips accidentally or through learned conventions (hovering over icons). This means tooltips should never be the only way to access important information.
When to use a Tooltip:
When NOT to use a Tooltip:
Fine-tune your tooltip's entrance timing with our Transition Generator — a 200–400ms delay prevents accidental triggers.
| Variant | Description | Use Case |
|---|---|---|
| Plain text | Simple text content, dark background, white text. | Icon button labels, abbreviation definitions |
| Rich | Includes a title and description. Still non-interactive. | Feature explanations, setting descriptions |
| With keyboard shortcut | Shows the shortcut alongside the label ("Copy ⌘C") | Toolbar buttons, menu items |
| Inverted | Light background, dark text. For dark-themed UIs. | Dark mode interfaces |
| Placement | When to Use |
|---|---|
| Top (default) | Most common. Works for most elements. |
| Bottom | When the element is near the top of the viewport. |
| Left / Right | For elements on the edge of the screen, or in vertical toolbars. |
| Auto | Automatically chooses placement based on available space. Preferred for reusable components. |
| Scenario | Open Delay | Close Delay |
|---|---|---|
| Standard | 200–400ms | 0ms (instant) |
| Toolbar (many tooltips close together) | 200ms for first, 0ms for subsequent (skip delay when moving between adjacent tooltips) | 0ms |
| Immediate (no delay) | 0ms | 0ms — Use only when the tooltip IS the label (icon buttons) |
| Property | Type | Default | Description |
|---|---|---|---|
content | string | ReactNode | — | Tooltip content (keep it brief — 1–2 lines max) |
placement | 'top' | 'bottom' | 'left' | 'right' | 'top' | Preferred position relative to the trigger |
delayDuration | number | 300 | Milliseconds before tooltip appears on hover |
skipDelayDuration | number | 300 | Time window for instant-show when moving between tooltips |
align | 'start' | 'center' | 'end' | 'center' | Alignment along the placement axis |
offset | number | 8 | Distance in pixels between tooltip and trigger |
arrow | boolean | true | Whether to show an arrow pointing to the trigger |
open | boolean | — | Controlled open state (overrides hover behavior) |
onOpenChange | (open: boolean) => void | — | Callback when open state changes |
children | ReactNode | — | The trigger element (must be focusable) |
| Token Category | Token Example | Tooltip Usage |
|---|---|---|
| Color – Background | --color-tooltip-bg (gray-900) | Dark background for contrast against page content |
| Color – Text | --color-tooltip-text (white) | High-contrast text on dark background |
| Color – Arrow | --color-tooltip-bg | Arrow inherits background color |
| Border Radius | --radius-sm (4px) | Subtle rounding. Tooltips should feel compact. |
| Shadow | --shadow-md | Subtle elevation to lift above content |
| Spacing | --space-1 (4px), --space-2 (8px) | Tight padding — tooltips are compact |
| Typography | --font-size-xs (12px), --font-weight-medium | Small text to keep footprint minimal |
| Transition | --duration-fast (150ms) | Fade-in animation. Configure with Transition Generator. |
| Z-index | --z-tooltip (50) | Above most content, below modals |
Tooltips on dark backgrounds need inverted tokens. Verify tooltip text contrast with our Contrast Checker — white text on gray-900 typically achieves 15:1+, but custom colors may not.
| State | Trigger Behavior | Tooltip Behavior |
|---|---|---|
| Hidden | No hover or focus on trigger | Not rendered (or rendered with opacity: 0 + pointer-events: none) |
| Delay | User has hovered over trigger, delay timer running | Not yet visible |
| Entering | Delay expired | Fades in (opacity 0→1) with optional slide (2–4px). Duration: 150ms. |
| Visible | Hover or focus maintained | Fully visible, positioned relative to trigger |
| Leaving | Hover/focus removed | Fades out immediately (0–100ms). Faster exit than entrance feels natural. |
| Skip-delay | User moved from one tooltip trigger to an adjacent one within skipDelayDuration | Opens immediately (no delay) |
When a tooltip's preferred position would overflow the viewport:
This collision detection is handled automatically by libraries like Floating UI. Don't build it yourself — edge cases are numerous and subtle.
| Criterion | Level | Requirement |
|---|---|---|
| SC 1.4.3 Contrast (Minimum) | AA | Tooltip text must have 4.5:1 contrast against the tooltip background. Default dark bg + white text easily passes. Verify custom themes with Contrast Checker. |
| SC 1.4.13 Content on Hover or Focus | AA | Critical for tooltips. Three requirements: (1) Dismissible — user can close without moving hover/focus (Escape key). (2) Hoverable — user can move pointer over the tooltip itself without it disappearing. (3) Persistent — tooltip stays until hover/focus is removed, the user dismisses it, or the info becomes invalid. |
| SC 4.1.2 Name, Role, Value | A | If the tooltip provides the accessible name for an element (icon button), the trigger must have aria-label or aria-labelledby. |
For naming tooltips (icon buttons):
<!-- Option A: aria-label on the trigger -->
<button type="button" aria-label="Delete item">
<svg aria-hidden="true"><!-- trash icon --></svg>
</button>
<!-- Tooltip appears on hover but is decorative — screen readers use aria-label -->
<!-- Option B: aria-describedby linking to tooltip -->
<button type="button" aria-describedby="tooltip-delete">
<svg aria-hidden="true"><!-- trash icon --></svg>
</button>
<div role="tooltip" id="tooltip-delete">Delete item</div>
For supplementary tooltips (additional context):
<span tabindex="0" aria-describedby="tooltip-sla">SLA</span>
<div role="tooltip" id="tooltip-sla">Service Level Agreement</div>
Key decisions:
role="tooltip" — Tells assistive tech this is a tooltip. Used with aria-describedby.aria-describedby — Links the trigger to the tooltip. Content is announced as a description (after the element's name and role).aria-labelledby with role="tooltip" for naming — use aria-label directly on the trigger instead. It's simpler and more reliable.| Key | Action |
|---|---|
| Tab (focus on trigger) | Shows the tooltip |
| Escape | Dismisses the tooltip (required by WCAG SC 1.4.13) |
| Tab away (blur) | Hides the tooltip |
Tooltips fundamentally don't work on touch devices — there is no "hover" interaction. Strategies:
For tooltip accessibility patterns, see our ARIA Attributes Guide and WCAG Practical Guide.
tabindex="0" if the trigger isn't natively focusable.<!-- Simple tooltip with CSS-only approach -->
<div class="tooltip-wrapper">
<button
type="button"
class="btn btn-ghost btn-icon"
aria-label="Delete item"
aria-describedby="tip-delete"
>
<svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52.149.023a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5Z" clip-rule="evenodd"/>
</svg>
</button>
<div role="tooltip" id="tip-delete" class="tooltip">
Delete item
</div>
</div>
<!-- Tooltip with keyboard shortcut -->
<div class="tooltip-wrapper">
<button
type="button"
class="btn btn-ghost btn-icon"
aria-label="Copy to clipboard"
aria-describedby="tip-copy"
>
<svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M7 3.5A1.5 1.5 0 0 1 8.5 2h3.879a1.5 1.5 0 0 1 1.06.44l3.122 3.12A1.5 1.5 0 0 1 17 6.622V12.5a1.5 1.5 0 0 1-1.5 1.5h-1v-3.379a3 3 0 0 0-.879-2.121L10.5 5.379A3 3 0 0 0 8.379 4.5H7v-1Z"/>
<path d="M4.5 6A1.5 1.5 0 0 0 3 7.5v9A1.5 1.5 0 0 0 4.5 18h7a1.5 1.5 0 0 0 1.5-1.5v-5.879a1.5 1.5 0 0 0-.44-1.06L9.44 6.439A1.5 1.5 0 0 0 8.378 6H4.5Z"/>
</svg>
</button>
<div role="tooltip" id="tip-copy" class="tooltip">
Copy to clipboard <kbd>⌘C</kbd>
</div>
</div>
<style>
.tooltip-wrapper { position: relative; display: inline-block; }
.tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
padding: 4px 8px;
background: var(--color-gray-900);
color: white;
font-size: 12px;
border-radius: 4px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 150ms ease;
}
.tooltip-wrapper:hover .tooltip,
.tooltip-wrapper:focus-within .tooltip {
opacity: 1;
pointer-events: auto;
}
</style>import {
useState,
useRef,
useEffect,
useCallback,
useId,
type ReactNode,
type CSSProperties,
} from "react";
interface TooltipProps {
content: string;
children: ReactNode;
placement?: "top" | "bottom" | "left" | "right";
delayMs?: number;
offset?: number;
}
export default function Tooltip({
content,
children,
placement = "top",
delayMs = 300,
offset = 8,
}: TooltipProps) {
const [open, setOpen] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const id = useId();
const show = useCallback(() => {
timeoutRef.current = setTimeout(() => setOpen(true), delayMs);
}, [delayMs]);
const hide = useCallback(() => {
clearTimeout(timeoutRef.current);
setOpen(false);
}, []);
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") hide();
};
if (open) document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, [open, hide]);
const positionStyle: CSSProperties = {
position: "absolute",
...(placement === "top" && { bottom: `calc(100% + ${offset}px)`, left: "50%", transform: "translateX(-50%)" }),
...(placement === "bottom" && { top: `calc(100% + ${offset}px)`, left: "50%", transform: "translateX(-50%)" }),
...(placement === "left" && { right: `calc(100% + ${offset}px)`, top: "50%", transform: "translateY(-50%)" }),
...(placement === "right" && { left: `calc(100% + ${offset}px)`, top: "50%", transform: "translateY(-50%)" }),
};
return (
<span
className="tooltip-wrapper"
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
style={{ position: "relative", display: "inline-block" }}
>
{children}
{open && (
<div role="tooltip" id={id} className="tooltip" style={positionStyle}>
{content}
</div>
)}
</span>
);
}
// Usage
<Tooltip content="Delete item" placement="top">
<button type="button" aria-label="Delete item" className="btn-icon">
<TrashIcon />
</button>
</Tooltip>
<Tooltip content="Copy to clipboard ⌘C" delayMs={200}>
<button type="button" aria-label="Copy to clipboard">
<CopyIcon />
</button>
</Tooltip>| Feature | Material 3 | Shadcn/ui | Radix | Ant Design |
|---|---|---|---|---|
| Component | Tooltip (Plain / Rich) | Tooltip (Radix-based) | Tooltip primitive | Tooltip |
| Rich content | "Rich tooltip" variant with title + actions | Text only (use Popover for rich) | Text only (primitive) | Any ReactNode |
| Delay | Not configurable (200ms default) | delayDuration prop | delayDuration + skipDelayDuration | mouseEnterDelay / mouseLeaveDelay |
| Skip delay | Not documented | Via Radix | TooltipProvider with skipDelayDuration | Not built-in |
| Arrow | Yes (centered) | Optional | Tooltip.Arrow | Yes (configurable) |
| Placement | Limited | 12 positions via Radix | 12 positions + collision detection | 12 positions |
| Touch behavior | Long-press shows tooltip | No touch support | No touch support | Not specified |
| Animation | Material motion | CSS fade (Tailwind) | BYO animation | CSS fade + slide |
Radix Tooltip introduces the concept of TooltipProvider — a wrapper that manages the "skip delay" behavior across all tooltips in your app. When a user hovers over one tooltip trigger and then quickly moves to another, the second tooltip appears instantly (no delay). This creates a fluid experience when exploring toolbar buttons. It's a small detail that dramatically improves usability.
Material 3 distinguishes between "Plain Tooltips" (label text only, for icon buttons) and "Rich Tooltips" (title + description + optional action, for detailed information). This formalization is valuable — most design systems blur the line between tooltip and popover. Material makes it explicit: if it has interactive content, it's not a tooltip.
Shadcn/ui wraps Radix's tooltip primitive with Tailwind styling and sensible defaults. The animation uses Tailwind's animate-in / animate-out classes with directional slide based on placement. It's clean and performant.
Ant Design is the most permissive — it allows any ReactNode as tooltip content, including links and interactive elements. This technically violates WCAG SC 1.4.13 guidelines (tooltip content should be non-interactive) and the WAI-ARIA tooltip pattern. If you need interactive content, use Ant's Popover instead.
CSS-only tooltips are tempting but limited. They can't handle collision detection (flipping when near viewport edge), can't be made keyboard-accessible without JavaScript (need Escape to dismiss per SC 1.4.13), and can't implement the hover-over-tooltip requirement. Use them only for decorative, non-essential enhancements.