Building an Accessible Tooltip in React: Hover, Focus, and ARIA DescribedBy
A comprehensive, step-by-step guide to building a fully accessible, WCAG-compliant tooltip component in React and TypeScript with complete keyboard dismissal and ARIA wiring.
Tooltips are one of the most commonly misunderstood UI components on the web. Often implemented as simple hover-triggered floating divs, they frequently fail basic accessibility audits due to missing keyboard focus handlers, broken screen reader associations, and a lack of proper escape-key dismissal.
In this technical guide, we will build a production-ready, accessible tooltip component in React using TypeScript. We will cover correct aria-describedby wiring, multi-modal interaction parity (mouse, touch, and keyboard), and robust focus-and-escape handling.
The Anatomy of an Accessible Tooltip
Before writing any code, let’s establish what makes a tooltip accessible according to the WAI-ARIA Authoring Practices Guide (APG):
- Semantic Association: The trigger element must reference the tooltip container via
aria-describedby(oraria-labelledby, depending on context). Screen readers will read this description when the trigger receives focus or is hovered. - Keyboard Accessibility: Tooltips must appear not only on mouse hover, but also when the trigger element receives keyboard focus (
onFocus). - Keyboard Dismissal: Users must be able to dismiss the tooltip by pressing the
Escapekey without moving focus away from the trigger element. - Non-Modal Nature: The tooltip must not trap keyboard focus. A user should be able to tab past the trigger element seamlessly.
Step 1: Component Architecture and Types
Let’s start by defining our TypeScript interfaces. We need a clean API that accepts children (the trigger element), the tooltip content, and an optional placement prop.
import React, {
useState,
useRef,
useId,
cloneElement,
ReactElement,
KeyboardEvent,
MouseEvent,
FocusEvent,
} from 'react';
type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
interface TooltipProps {
content: React.ReactNode;
children: ReactElement;
position?: TooltipPosition;
delay?: number;
}
Step 2: Managing State and ARIA Relationships
To link our trigger element to our tooltip popup dynamically, we need a unique identifier. React 18’s useId hook is perfect for generating stable, SSR-safe IDs.
We also need states to track visibility and a mechanism to handle delays so the tooltip doesn’t flash erratically during rapid mouse movements.
export const Tooltip: React.FC<TooltipProps> = ({
content,
children,
position = 'top',
delay = 200,
}) => {
const [isVisible, setIsVisible] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const tooltipId = useId();
const showTooltip = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setIsVisible(true);
}, delay);
};
const hideTooltip = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setIsVisible(false);
};
// ... handlers go here
};
Step 3: Handling Mouse, Focus, and Keyboard Events
Parity across input modalities is critical. A mouse user triggers a hover; a keyboard user triggers a focus event. We must handle both gracefully.
Furthermore, the WAI-ARIA specification requires that pressing Escape dismisses the tooltip immediately.
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isVisible) {
e.stopPropagation();
hideTooltip();
}
};
// Extract existing handlers from children to avoid overriding them
const childProps = children.props;
const triggerProps = {
...childProps,
'aria-describedby': isVisible ? tooltipId : undefined,
onMouseEnter: (e: MouseEvent) => {
childProps.onMouseEnter?.(e);
showTooltip();
},
onMouseLeave: (e: MouseEvent) => {
childProps.onMouseLeave?.(e);
hideTooltip();
},
onFocus: (e: FocusEvent) => {
childProps.onFocus?.(e);
showTooltip();
},
onBlur: (e: FocusEvent) => {
childProps.onBlur?.(e);
hideTooltip();
},
onKeyDown: (e: KeyboardEvent) => {
childProps.onKeyDown?.(e);
handleKeyDown(e);
},
};
Notice how aria-describedby is conditionally applied only when isVisible is true. While many implementations keep the attribute present permanently, toggling it ensures screen readers don’t read stale or redundant text when the tooltip is hidden.
Step 4: Rendering and Positioning
Next, we clone the child element to inject our accessibility props and render the tooltip popup conditionally. We use role="tooltip" so assistive technologies recognize the element’s specific semantic role.
return (
<div className="tooltip-wrapper" style={{ position: 'relative', display: 'inline-block' }}>
{cloneElement(children, triggerProps)}
{isVisible && (
<div
id={tooltipId}
role="tooltip"
className={`tooltip-popup tooltip-${position}`}
>
{content}
</div>
)}
</div>
);
};
Step 5: Styling for Position and Transitions
To make our tooltip look professional, we need simple CSS classes to handle absolute positioning and smooth opacity transitions.
.tooltip-wrapper {
display: inline-block;
}
.tooltip-popup {
position: absolute;
z-index: 1000;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
color: #fff;
background-color: #1a1a1a;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
animation: tooltip-fade 150ms ease-in-out;
}
.tooltip-top {
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
}
.tooltip-bottom {
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
}
@keyframes tooltip-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
Tip: Setting
pointer-events: none;on the tooltip element ensures the user’s cursor doesn’t accidentally trigger a mouseleave event when hovering directly over the tooltip box itself, preventing annoying flickering behavior.
Putting It All Together
Here is the complete, cohesive React component code ready to drop into your design system:
import React, {
useState,
useRef,
useId,
cloneElement,
ReactElement,
KeyboardEvent,
MouseEvent,
FocusEvent,
} from 'react';
type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
interface TooltipProps {
content: React.ReactNode;
children: ReactElement;
position?: TooltipPosition;
delay?: number;
}
export const Tooltip: React.FC<TooltipProps> = ({
content,
children,
position = 'top',
delay = 200,
}) => {
const [isVisible, setIsVisible] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const tooltipId = useId();
const showTooltip = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setIsVisible(true);
}, delay);
};
const hideTooltip = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setIsVisible(false);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isVisible) {
e.stopPropagation();
hideTooltip();
}
};
const childProps = children.props;
const triggerProps = {
...childProps,
'aria-describedby': isVisible ? tooltipId : undefined,
onMouseEnter: (e: MouseEvent) => {
childProps.onMouseEnter?.(e);
showTooltip();
},
onMouseLeave: (e: MouseEvent) => {
childProps.onMouseLeave?.(e);
hideTooltip();
},
onFocus: (e: FocusEvent) => {
childProps.onFocus?.(e);
showTooltip();
},
onBlur: (e: FocusEvent) => {
childProps.onBlur?.(e);
hideTooltip();
},
onKeyDown: (e: KeyboardEvent) => {
childProps.onKeyDown?.(e);
handleKeyDown(e);
},
};
return (
<div className="tooltip-wrapper">
{cloneElement(children, triggerProps)}
{isVisible && (
<div
id={tooltipId}
role="tooltip"
className={`tooltip-popup tooltip-${position}`}
>
{content}
</div>
)}
</div>
);
};
Conclusion
Building accessible components requires thinking beyond visual design and mouse interactions. By combining React’s cloneElement pattern with proper aria-describedby wiring, focus management, and Escape key dismissal, we create a robust tooltip that provides an equal, frictionless experience for keyboard navigators, screen reader users, and mouse users alike.