Create accessible tooltips
Tooltips are accessible to keyboard users and screen readers with proper ARIA attributes and focus handling.
- Tooltips must be keyboard accessible (focusable trigger)
- Use aria-describedby to associate tooltip with trigger
- Allow hover and focus to show tooltip, Escape to dismiss
- Ensure sufficient contrast and don't hide essential info in tooltips
Rule Details
Accessible tooltips provide supplementary information without excluding keyboard or screen reader users.
Code Example
<div class="tooltip-container">
<button
type="button"
aria-describedby="tooltip-1"
class="tooltip-trigger"
>
Settings
</button>
<div
id="tooltip-1"
role="tooltip"
class="tooltip"
>
Configure your preferences
</div>
</div>Why It Matters
Inaccessible tooltips leave keyboard and screen reader users without important contextual information, creating an unequal experience and potential confusion.
Accessibility Requirements
| Requirement | Implementation |
|---|---|
| Keyboard accessible | Show on focus, not just hover |
| Programmatically associated | Use aria-describedby |
| Dismissible | Close with Escape key |
| Persistent | Stay visible while hovered/focused |
| Non-essential | Don't hide critical info in tooltips |
React Tooltip Component
import { useState, useRef, useEffect, useId } from 'react'
interface TooltipProps {
content: string
children: React.ReactElement
position?: 'top' | 'bottom' | 'left' | 'right'
delay?: number
}
export function Tooltip({
content,
children,
position = 'top',
delay = 300
}: TooltipProps) {
const [isVisible, setIsVisible] = useState(false)
const tooltipId = useId()
const timeoutRef = useRef<NodeJS.Timeout>()
const triggerRef = useRef<HTMLElement>(null)
const showTooltip = () => {
timeoutRef.current = setTimeout(() => setIsVisible(true), delay)
}
const hideTooltip = () => {
clearTimeout(timeoutRef.current)
setIsVisible(false)
}
// Handle Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isVisible) {
hideTooltip()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isVisible])
// Clone child to add props
const trigger = React.cloneElement(children, {
ref: triggerRef,
'aria-describedby': isVisible ? tooltipId : undefined,
onMouseEnter: showTooltip,
onMouseLeave: hideTooltip,
onFocus: showTooltip,
onBlur: hideTooltip,
})
return (
<div className="tooltip-wrapper">
{trigger}
{isVisible && (
<div
id={tooltipId}
role="tooltip"
className={`tooltip tooltip--${position}`}
>
{content}
</div>
)}
</div>
)
}Usage
<Tooltip content="Save your current progress">
<button type="button">
<SaveIcon aria-hidden="true" />
<span className="sr-only">Save</span>
</button>
</Tooltip>
<Tooltip content="Required field" position="right">
<label htmlFor="email">
Email <span aria-hidden="true">*</span>
</label>
</Tooltip>Advanced Tooltip with Floating UI
import { useFloating, offset, flip, shift, arrow } from '@floating-ui/react'
import { useState, useRef, useId } from 'react'
interface AdvancedTooltipProps {
content: React.ReactNode
children: React.ReactElement
}
export function AdvancedTooltip({ content, children }: AdvancedTooltipProps) {
const [isOpen, setIsOpen] = useState(false)
const arrowRef = useRef(null)
const tooltipId = useId()
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement: 'top',
middleware: [
offset(8),
flip(),
shift({ padding: 8 }),
arrow({ element: arrowRef })
],
})
return (
<>
{React.cloneElement(children, {
ref: refs.setReference,
'aria-describedby': isOpen ? tooltipId : undefined,
onMouseEnter: () => setIsOpen(true),
onMouseLeave: () => setIsOpen(false),
onFocus: () => setIsOpen(true),
onBlur: () => setIsOpen(false),
})}
{isOpen && (
<div
ref={refs.setFloating}
id={tooltipId}
role="tooltip"
style={floatingStyles}
className="tooltip"
>
{content}
<div ref={arrowRef} className="tooltip-arrow" />
</div>
)}
</>
)
}Icon Button with Tooltip
interface IconButtonProps {
icon: React.ReactNode
label: string
onClick: () => void
tooltip?: string
}
export function IconButton({ icon, label, onClick, tooltip }: IconButtonProps) {
const tooltipId = useId()
const [showTooltip, setShowTooltip] = useState(false)
return (
<div className="icon-button-wrapper">
<button
type="button"
onClick={onClick}
aria-label={label}
aria-describedby={tooltip && showTooltip ? tooltipId : undefined}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
onFocus={() => setShowTooltip(true)}
onBlur={() => setShowTooltip(false)}
className="icon-button"
>
{icon}
</button>
{tooltip && showTooltip && (
<span id={tooltipId} role="tooltip" className="tooltip">
{tooltip}
</span>
)}
</div>
)
}Native Title Attribute (Limited)
<!-- Simple but limited accessibility -->
<button type="button" title="Save document">
<svg aria-hidden="true"><!-- save icon --></svg>
<span class="sr-only">Save</span>
</button>
<!-- Better: Custom tooltip with full control -->
<button
type="button"
aria-describedby="save-tooltip"
aria-label="Save"
>
<svg aria-hidden="true"><!-- save icon --></svg>
</button>
<div id="save-tooltip" role="tooltip" class="tooltip">
Save document (Ctrl+S)
</div>Styling
.tooltip-wrapper {
position: relative;
display: inline-block;
}
.tooltip {
position: absolute;
z-index: 1000;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
background: #1a1a1a;
color: #ffffff;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
/* Animation */
opacity: 0;
animation: tooltipFadeIn 0.15s ease-out forwards;
}
.tooltip--top {
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 8px;
}
.tooltip--bottom {
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 8px;
}
.tooltip--left {
right: 100%;
top: 50%;
transform: translateY(-50%);
margin-right: 8px;
}
.tooltip--right {
left: 100%;
top: 50%;
transform: translateY(-50%);
margin-left: 8px;
}
/* Arrow */
.tooltip::after {
content: '';
position: absolute;
border: 6px solid transparent;
}
.tooltip--top::after {
top: 100%;
left: 50%;
transform: translateX(-50%);
border-top-color: #1a1a1a;
}
@keyframes tooltipFadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(4px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* Respect motion preferences */
@media (prefers-reduced-motion: reduce) {
.tooltip {
animation: none;
opacity: 1;
}
}
/* Ensure sufficient contrast */
.tooltip {
/* WCAG requires 4.5:1 for normal text */
/* #ffffff on #1a1a1a = 16.1:1 ✓ */
}When to Use Tooltips vs Other Patterns
| Use Case | Pattern |
|---|---|
| Supplementary hint | Tooltip |
| Essential instruction | Inline text |
| Complex content | Popover/Dialog |
| Form field help | aria-describedby text |
| Icon-only button | aria-label + optional tooltip |
Verification
- Tab to trigger element - tooltip should appear
- Press Escape - tooltip should close
- Hover over trigger - tooltip appears after delay
- Move mouse to tooltip - should stay visible
- Test with screen reader (tooltip content announced)
- Verify tooltip doesn't block other content
- Check color contrast meets WCAG requirements
Never put essential information in tooltips. They're for supplementary hints only. Critical content should be visible by default or in the main UI.
Use with AI
Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.
Check
Verify implementation
Verify tooltips are keyboard accessible, use appropriate ARIA roles (tooltip), and can be dismissed without moving focus.
Fix
Auto-fix issues
Implement tooltips with role='tooltip', aria-describedby, keyboard triggering, and Escape key dismissal.
Explain
Learn more
Explain the accessibility requirements for tooltips including keyboard access, ARIA attributes, and timing considerations.
Review
Code review
Review templates, server-rendered HTML, and shared components that output markup related to Create accessible tooltips. Flag exact elements, attributes, and routes where the rendered HTML violates the rule.