Skip to content

Tooltip

A small text popup that appears on hover or focus to describe an element.

  • Hint
  • Info Tip

Overview

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:

  • To label icon-only buttons (the tooltip IS the accessible name)
  • To define technical terms or abbreviations inline
  • To show the full value of truncated text (long file names, email addresses)
  • To provide brief context ("This setting affects all team members")

When NOT to use a Tooltip:

  • For critical information the user needs to complete a task — put it in the UI directly
  • For rich content (images, links, interactive elements) — use a Popover
  • For error messages or validation — use inline error text on the Text Input
  • For lengthy explanations — use helper text, an Accordion, or documentation links
  • On mobile — tooltips require hover, which doesn't exist on touch devices. Use a Popover with tap-to-open instead.

Fine-tune your tooltip's entrance timing with our Transition Generator — a 200–400ms delay prevents accidental triggers.

Variants

Tooltip Variants

VariantDescriptionUse Case
Plain textSimple text content, dark background, white text.Icon button labels, abbreviation definitions
RichIncludes a title and description. Still non-interactive.Feature explanations, setting descriptions
With keyboard shortcutShows the shortcut alongside the label ("Copy ⌘C")Toolbar buttons, menu items
InvertedLight background, dark text. For dark-themed UIs.Dark mode interfaces

Placement Variants

PlacementWhen to Use
Top (default)Most common. Works for most elements.
BottomWhen the element is near the top of the viewport.
Left / RightFor elements on the edge of the screen, or in vertical toolbars.
AutoAutomatically chooses placement based on available space. Preferred for reusable components.

Arrow / No Arrow

  • With arrow: Points to the trigger element. Clarifies association.
  • Without arrow: Cleaner look. Common in modern design systems (Shadcn, Radix default).

Delay Behavior

ScenarioOpen DelayClose Delay
Standard200–400ms0ms (instant)
Toolbar (many tooltips close together)200ms for first, 0ms for subsequent (skip delay when moving between adjacent tooltips)0ms
Immediate (no delay)0ms0ms — Use only when the tooltip IS the label (icon buttons)

Properties

Tooltip Properties

PropertyTypeDefaultDescription
contentstring | ReactNode—Tooltip content (keep it brief — 1–2 lines max)
placement'top' | 'bottom' | 'left' | 'right''top'Preferred position relative to the trigger
delayDurationnumber300Milliseconds before tooltip appears on hover
skipDelayDurationnumber300Time window for instant-show when moving between tooltips
align'start' | 'center' | 'end''center'Alignment along the placement axis
offsetnumber8Distance in pixels between tooltip and trigger
arrowbooleantrueWhether to show an arrow pointing to the trigger
openboolean—Controlled open state (overrides hover behavior)
onOpenChange(open: boolean) => void—Callback when open state changes
childrenReactNode—The trigger element (must be focusable)

Token Mappings

Design Token Mappings

Token CategoryToken ExampleTooltip 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-bgArrow inherits background color
Border Radius--radius-sm (4px)Subtle rounding. Tooltips should feel compact.
Shadow--shadow-mdSubtle elevation to lift above content
Spacing--space-1 (4px), --space-2 (8px)Tight padding — tooltips are compact
Typography--font-size-xs (12px), --font-weight-mediumSmall 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.

States

Tooltip States

StateTrigger BehaviorTooltip Behavior
HiddenNo hover or focus on triggerNot rendered (or rendered with opacity: 0 + pointer-events: none)
DelayUser has hovered over trigger, delay timer runningNot yet visible
EnteringDelay expiredFades in (opacity 0→1) with optional slide (2–4px). Duration: 150ms.
VisibleHover or focus maintainedFully visible, positioned relative to trigger
LeavingHover/focus removedFades out immediately (0–100ms). Faster exit than entrance feels natural.
Skip-delayUser moved from one tooltip trigger to an adjacent one within skipDelayDurationOpens immediately (no delay)

Positioning & Collision

When a tooltip's preferred position would overflow the viewport:

  1. Flip: Switch to the opposite side (top ↔ bottom, left ↔ right)
  2. Shift: Slide along the axis to stay in bounds while keeping the same side
  3. Auto: Let the positioning engine (Floating UI / Popper) choose the best placement

This collision detection is handled automatically by libraries like Floating UI. Don't build it yourself — edge cases are numerous and subtle.

Accessibility

WCAG Requirements

CriterionLevelRequirement
SC 1.4.3 Contrast (Minimum)AATooltip 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 FocusAACritical 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, ValueAIf the tooltip provides the accessible name for an element (icon button), the trigger must have aria-label or aria-labelledby.

ARIA Implementation

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).
  • Don't use aria-labelledby with role="tooltip" for naming — use aria-label directly on the trigger instead. It's simpler and more reliable.

Keyboard Interaction

KeyAction
Tab (focus on trigger)Shows the tooltip
EscapeDismisses the tooltip (required by WCAG SC 1.4.13)
Tab away (blur)Hides the tooltip

Touch Device Considerations

Tooltips fundamentally don't work on touch devices — there is no "hover" interaction. Strategies:

  1. Skip tooltips on touch — ensure all critical info is available without them
  2. Tap-to-toggle — first tap shows tooltip, second tap activates the button. Bad UX, confusing double-tap.
  3. Use a Popover instead — triggered by tap, dismissed by tap outside. This is the best approach for mobile.
  4. Long-press — show tooltip on long-press. Discoverable only by convention.

For tooltip accessibility patterns, see our ARIA Attributes Guide and WCAG Practical Guide.

Usage Guidelines

Do's

  • ✅ Keep tooltip text to 1–2 short sentences maximum. If you need more, use a Popover.
  • ✅ Add a hover delay (200–400ms). Without it, tooltips flash constantly as users move their mouse across the page. Annoying.
  • ✅ Make tooltips hoverable (SC 1.4.13). Users should be able to move their cursor from the trigger to the tooltip without it disappearing. This is essential for users with motor impairments.
  • ✅ Provide keyboard access. Tooltips must show on focus, not just hover. tabindex="0" if the trigger isn't natively focusable.
  • ✅ Use Escape to dismiss. Required by WCAG and expected by keyboard users.

Don'ts

  • ❌ Don't put interactive content in tooltips. Links, buttons, form controls — none of these belong in a tooltip. Use a Popover for interactive overlays.
  • ❌ Don't use tooltips for form validation. Error messages must be persistent and visible without hovering. Use inline error text.
  • ❌ Don't repeat the visible label. If a button says "Save", a tooltip that also says "Save" is redundant noise.
  • ❌ Don't use tooltips as a crutch for bad UX. If users constantly need tooltips to understand your interface, the interface is the problem — not the absence of tooltips.
  • ❌ Don't show tooltips on disabled elements without careful consideration. If you do, explain why it's disabled ("Requires admin permissions").

Content Guidelines

  • Naming tooltips: Match the accessible name exactly — "Delete", "Copy link", "Open settings".
  • Supplementary tooltips: One sentence, no period. "Last updated 3 hours ago" not "This shows when the item was last updated by a team member."
  • Keyboard shortcuts: Format consistently — "Copy (⌘C)" or "Copy · ⌘C". Use the correct modifier for the platform (⌘ on Mac, Ctrl on Windows).

Code Snippets

html
<!-- 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>
tsx
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>

Design Systems

Cross-System Comparison

FeatureMaterial 3Shadcn/uiRadixAnt Design
ComponentTooltip (Plain / Rich)Tooltip (Radix-based)Tooltip primitiveTooltip
Rich content"Rich tooltip" variant with title + actionsText only (use Popover for rich)Text only (primitive)Any ReactNode
DelayNot configurable (200ms default)delayDuration propdelayDuration + skipDelayDurationmouseEnterDelay / mouseLeaveDelay
Skip delayNot documentedVia RadixTooltipProvider with skipDelayDurationNot built-in
ArrowYes (centered)OptionalTooltip.ArrowYes (configurable)
PlacementLimited12 positions via Radix12 positions + collision detection12 positions
Touch behaviorLong-press shows tooltipNo touch supportNo touch supportNot specified
AnimationMaterial motionCSS fade (Tailwind)BYO animationCSS fade + slide

Notable Approaches

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.

OverlayHintInfo Tip