Tooltip
A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.
🤖 AI Implementation GuideDemo
Accessibility Features
WAI-ARIA Roles
-
tooltip- A contextual popup that displays a description for an element
WAI-ARIA tooltip role (opens in new tab)
WAI-ARIA States & Properties
aria-describedby
References the tooltip element to provide an accessible description for the trigger element.
| Applied to | Trigger element (wrapper) |
| When | Only when tooltip is visible |
| Reference | aria-describedby (opens in new tab) |
aria-hidden
Indicates whether the tooltip is hidden from assistive technology.
| Values | true (hidden) | false (visible)
|
| Default | true |
| Reference | aria-hidden (opens in new tab) |
Keyboard Support
| Key | Action |
|---|---|
| Escape | Closes the tooltip |
| Tab | Standard focus navigation; tooltip shows when trigger receives focus |
Focus Management
- Tooltip never receives focus - Per APG, tooltips must not be focusable. If interactive content is needed, use a Dialog or Popover pattern instead.
- Focus triggers display - When the trigger element receives focus, the tooltip appears after the configured delay.
- Blur hides tooltip - When focus leaves the trigger element, the tooltip is hidden.
Mouse/Pointer Behavior
- Hover triggers display - Moving the pointer over the trigger shows the tooltip after the delay.
- Pointer leave hides - Moving the pointer away from the trigger hides the tooltip.
Important Notes
Note: The APG Tooltip pattern is currently marked as "work in progress" by the WAI. This implementation follows the documented guidelines, but the specification may evolve. View APG Tooltip Pattern (opens in new tab)
Visual Design
This implementation follows best practices for tooltip visibility:
- High contrast - Dark background with light text ensures readability
- Dark mode support - Colors invert appropriately in dark mode
- Positioned near trigger - Tooltip appears adjacent to the triggering element
- Configurable delay - Prevents accidental activation during cursor movement
Source Code
Tooltip.tsx
import { cn } from '@/lib/utils';
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react';
export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';
export interface TooltipProps {
/** Tooltip content */
content: ReactNode;
/** Trigger element */
children: ReactNode;
/** Controlled open state */
open?: boolean;
/** Default open state (uncontrolled) */
defaultOpen?: boolean;
/** Callback when open state changes */
onOpenChange?: (open: boolean) => void;
/** Delay before showing tooltip (ms) */
delay?: number;
/** Tooltip placement */
placement?: TooltipPlacement;
/** Custom tooltip ID for SSR */
id?: string;
/** Whether the tooltip is disabled */
disabled?: boolean;
/** Additional class name for the wrapper */
className?: string;
/** Additional class name for the tooltip content */
tooltipClassName?: string;
}
export const Tooltip: React.FC<TooltipProps> = ({
content,
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
delay = 300,
placement = 'top',
id: providedId,
disabled = false,
className,
tooltipClassName,
}) => {
const generatedId = useId();
const tooltipId = providedId ?? `tooltip-${generatedId}`;
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled ? controlledOpen : internalOpen;
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const triggerRef = useRef<HTMLSpanElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const setOpen = useCallback(
(value: boolean) => {
if (!isControlled) {
setInternalOpen(value);
}
onOpenChange?.(value);
},
[isControlled, onOpenChange]
);
const showTooltip = useCallback(() => {
if (disabled) return;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setOpen(true);
}, delay);
}, [delay, disabled, setOpen]);
const hideTooltip = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setOpen(false);
}, [setOpen]);
// Handle Escape key
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
hideTooltip();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, hideTooltip]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const placementClasses: Record<TooltipPlacement, string> = {
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
};
return (
<span
ref={triggerRef}
className={cn('apg-tooltip-trigger', 'relative inline-block', className)}
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}
aria-describedby={isOpen && !disabled ? tooltipId : undefined}
>
{children}
<span
ref={tooltipRef}
id={tooltipId}
role="tooltip"
aria-hidden={!isOpen}
className={cn(
'apg-tooltip',
'absolute z-50 px-3 py-1.5 text-sm',
'rounded-md bg-gray-900 text-white shadow-lg',
'dark:bg-gray-100 dark:text-gray-900',
'pointer-events-none whitespace-nowrap',
'transition-opacity duration-150',
placementClasses[placement],
isOpen ? 'visible opacity-100' : 'invisible opacity-0',
tooltipClassName
)}
>
{content}
</span>
</span>
);
};
export default Tooltip; Usage
Example
import { Tooltip } from './Tooltip';
function App() {
return (
<Tooltip
content="Save your changes"
placement="top"
delay={300}
>
<button>Save</button>
</Tooltip>
);
} API
| Prop | Type | Default | Description |
|---|---|---|---|
content | ReactNode | - | Tooltip content (required) |
children | ReactNode | - | Trigger element (required) |
open | boolean | - | Controlled open state |
defaultOpen | boolean | false | Default open state (uncontrolled) |
onOpenChange | (open: boolean) => void | - | Callback when open state changes |
delay | number | 300 | Delay before showing (ms) |
placement | 'top' | 'bottom' | 'left' | 'right' | 'top' | Tooltip position relative to trigger |
id | string | auto-generated | Custom ID for SSR |
disabled | boolean | false | Disable the tooltip |
Testing
Testing Overview
The Tooltip component tests are organized into priority levels based on APG compliance requirements.
Test Categories
High Priority: APG Core Compliance
| Test | APG Requirement |
|---|---|
| role="tooltip" exists | Tooltip container must have tooltip role |
| aria-hidden when closed | Hidden tooltips must not be read by AT |
| aria-describedby when visible | Trigger must reference tooltip only when visible |
| Escape key closes tooltip | Keyboard dismissal support |
| Focus shows tooltip | Keyboard accessibility |
| Blur hides tooltip | Focus management |
Medium Priority: Accessibility Validation
| Test | WCAG Requirement |
|---|---|
| No axe violations (hidden state) | WCAG 2.1 AA compliance |
| No axe violations (visible state) | WCAG 2.1 AA compliance |
| Tooltip is not focusable | APG: tooltips must not receive focus |
Low Priority: Props & Extensibility
| Test | Feature |
|---|---|
| placement prop changes position | Positioning customization |
| disabled prop prevents display | Disable functionality |
| delay prop controls timing | Delay customization |
| id prop sets custom ID | SSR/custom ID support |
| controlled open state | External state control |
| onOpenChange callback | State change notification |
| className inheritance | Style customization |
Running Tests
# Run all Tooltip tests
npm run test -- tooltip
# Run tests for specific framework
npm run test -- Tooltip.test.tsx # React
npm run test -- Tooltip.test.vue # Vue
npm run test -- Tooltip.test.svelte # Svelte Tooltip.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Tooltip } from './Tooltip';
describe('Tooltip', () => {
// 🔴 High Priority: APG Core Compliance
describe('APG: ARIA Attributes', () => {
it('has role="tooltip"', () => {
render(
<Tooltip content="This is a tooltip">
<button>Hover me</button>
</Tooltip>
);
expect(screen.getByRole('tooltip', { hidden: true })).toBeInTheDocument();
});
it('has aria-hidden="true" when hidden', () => {
render(
<Tooltip content="This is a tooltip">
<button>Hover me</button>
</Tooltip>
);
const tooltip = screen.getByRole('tooltip', { hidden: true });
expect(tooltip).toHaveAttribute('aria-hidden', 'true');
});
it('has aria-hidden="false" when visible', async () => {
const user = userEvent.setup();
render(
<Tooltip content="This is a tooltip" delay={0}>
<button>Hover me</button>
</Tooltip>
);
const trigger = screen.getByRole('button');
await user.hover(trigger);
await waitFor(() => {
const tooltip = screen.getByRole('tooltip');
expect(tooltip).toHaveAttribute('aria-hidden', 'false');
});
});
it('sets aria-describedby only when visible', async () => {
const user = userEvent.setup();
render(
<Tooltip content="This is a tooltip" delay={0}>
<button>Hover me</button>
</Tooltip>
);
const trigger = screen.getByRole('button');
const wrapper = trigger.parentElement;
// No aria-describedby when hidden
expect(wrapper).not.toHaveAttribute('aria-describedby');
await user.hover(trigger);
await waitFor(() => {
expect(wrapper).toHaveAttribute('aria-describedby');
});
const tooltipId = wrapper?.getAttribute('aria-describedby');
const tooltip = screen.getByRole('tooltip');
expect(tooltip).toHaveAttribute('id', tooltipId);
});
});
describe('APG: Keyboard Interaction', () => {
it('closes with Escape key', async () => {
const user = userEvent.setup();
render(
<Tooltip content="This is a tooltip" delay={0}>
<button>Hover me</button>
</Tooltip>
);
const trigger = screen.getByRole('button');
await user.hover(trigger);
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
});
await user.keyboard('{Escape}');
await waitFor(() => {
expect(screen.getByRole('tooltip', { hidden: true })).toHaveAttribute(
'aria-hidden',
'true'
);
});
});
it('shows on focus', async () => {
const user = userEvent.setup();
render(
<Tooltip content="This is a tooltip" delay={0}>
<button>Hover me</button>
</Tooltip>
);
await user.tab();
expect(screen.getByRole('button')).toHaveFocus();
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
});
});
it('closes on focus out', async () => {
const user = userEvent.setup();
render(
<>
<Tooltip content="This is a tooltip" delay={0}>
<button>First</button>
</Tooltip>
<button>Second</button>
</>
);
await user.tab();
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
});
await user.tab();
await waitFor(() => {
expect(screen.getByRole('tooltip', { hidden: true })).toHaveAttribute(
'aria-hidden',
'true'
);
});
});
});
describe('Hover Interaction', () => {
afterEach(() => {
vi.useRealTimers();
});
it('shows on hover', async () => {
const user = userEvent.setup();
render(
<Tooltip content="This is a tooltip" delay={0}>
<button>Hover me</button>
</Tooltip>
);
const trigger = screen.getByRole('button');
await user.hover(trigger);
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
});
});
it('closes on hover out', async () => {
const user = userEvent.setup();
render(
<Tooltip content="This is a tooltip" delay={0}>
<button>Hover me</button>
</Tooltip>
);
const trigger = screen.getByRole('button');
await user.hover(trigger);
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
});
await user.unhover(trigger);
await waitFor(() => {
expect(screen.getByRole('tooltip', { hidden: true })).toHaveAttribute(
'aria-hidden',
'true'
);
});
});
it('shows after delay', async () => {
const user = userEvent.setup();
render(
<Tooltip content="This is a tooltip" delay={100}>
<button>Hover me</button>
</Tooltip>
);
const trigger = screen.getByRole('button');
await user.hover(trigger);
// Hidden immediately before delay
expect(screen.getByRole('tooltip', { hidden: true })).toHaveAttribute('aria-hidden', 'true');
// Visible after delay
await waitFor(
() => {
expect(screen.getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
},
{ timeout: 200 }
);
});
});
// 🟡 Medium Priority: Accessibility Validation
describe('Accessibility', () => {
it('has no WCAG 2.1 AA violations (hidden state)', async () => {
const { container } = render(
<Tooltip content="This is a tooltip">
<button>Hover me</button>
</Tooltip>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no WCAG 2.1 AA violations (visible state)', async () => {
const user = userEvent.setup();
const { container } = render(
<Tooltip content="This is a tooltip" delay={0}>
<button>Hover me</button>
</Tooltip>
);
await user.hover(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('tooltip does not receive focus', () => {
render(
<Tooltip content="This is a tooltip">
<button>Hover me</button>
</Tooltip>
);
const tooltip = screen.getByRole('tooltip', { hidden: true });
expect(tooltip).not.toHaveAttribute('tabindex');
});
});
describe('Props', () => {
it('can change position with placement prop', () => {
render(
<Tooltip content="Tooltip" placement="bottom">
<button>Hover me</button>
</Tooltip>
);
const tooltip = screen.getByRole('tooltip', { hidden: true });
expect(tooltip).toHaveClass('top-full');
});
it('does not show tooltip when disabled', async () => {
const user = userEvent.setup();
render(
<Tooltip content="Tooltip" delay={0} disabled>
<button>Hover me</button>
</Tooltip>
);
const trigger = screen.getByRole('button');
await user.hover(trigger);
// Does not show because disabled (delay=0 so immediate)
expect(screen.getByRole('tooltip', { hidden: true })).toHaveAttribute('aria-hidden', 'true');
});
it('can set custom ID with id prop', () => {
render(
<Tooltip content="Tooltip" id="custom-tooltip-id">
<button>Hover me</button>
</Tooltip>
);
const tooltip = screen.getByRole('tooltip', { hidden: true });
expect(tooltip).toHaveAttribute('id', 'custom-tooltip-id');
});
it('calls onOpenChange when state changes', async () => {
const handleOpenChange = vi.fn();
const user = userEvent.setup();
render(
<Tooltip content="Tooltip" delay={0} onOpenChange={handleOpenChange}>
<button>Hover me</button>
</Tooltip>
);
const trigger = screen.getByRole('button');
await user.hover(trigger);
await waitFor(() => {
expect(handleOpenChange).toHaveBeenCalledWith(true);
});
await user.unhover(trigger);
await waitFor(() => {
expect(handleOpenChange).toHaveBeenCalledWith(false);
});
});
it('can be controlled with open prop', () => {
const { rerender } = render(
<Tooltip content="Tooltip" open={false}>
<button>Hover me</button>
</Tooltip>
);
expect(screen.getByRole('tooltip', { hidden: true })).toHaveAttribute('aria-hidden', 'true');
rerender(
<Tooltip content="Tooltip" open={true}>
<button>Hover me</button>
</Tooltip>
);
expect(screen.getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
});
});
// 🟢 Low Priority: Extensibility
describe('HTML Attribute Inheritance', () => {
it('merges className correctly', () => {
render(
<Tooltip content="Tooltip" className="custom-class">
<button>Hover me</button>
</Tooltip>
);
const wrapper = screen.getByRole('button').parentElement;
expect(wrapper).toHaveClass('custom-class');
expect(wrapper).toHaveClass('apg-tooltip-trigger');
});
it('applies tooltipClassName', () => {
render(
<Tooltip content="Tooltip" tooltipClassName="custom-tooltip">
<button>Hover me</button>
</Tooltip>
);
const tooltip = screen.getByRole('tooltip', { hidden: true });
expect(tooltip).toHaveClass('custom-tooltip');
});
});
}); Resources
- WAI-ARIA APG: Tooltip Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist