Building an Accessible Tooltip in React: Hover, Focus, and ARIA DescribedBy
A deep-dive technical walkthrough on implementing a robust React tooltip from scratch, covering dual-trigger handling, dynamic positioning, and screen reader announcements.
Building an Accessible Tooltip in React: Hover, Focus, and ARIA DescribedBy
Tooltips are ubiquitous interface elements. They provide supplementary context, definitions, or action hints when a user hovers over an element. However, building a truly accessible tooltip is surprisingly challenging.
Many naive implementations only listen for mouse events (onMouseEnter and onMouseLeave), completely ignoring keyboard users who navigate via Tab. Furthermore, they often fail to handle screen reader accessibility trees correctly, relying on native title attributes that suffer from inconsistent browser rendering delays, lack of styling control, and poor mobile support.
In this deep-dive, we will build a production-ready, highly accessible Tooltip component in React using TypeScript. We will cover:
- Dual-trigger event handling for mouse hover and keyboard focus.
- Dynamic positioning with boundary collision detection.
- Screen reader integration using
aria-describedby. - Clean lifecycle and memory management.
The Architecture of an Accessible Tooltip
To ensure our tooltip works universally, it must satisfy three core interaction pillars:
- Mouse Parity: Moving the mouse over the trigger shows the tooltip; moving it away hides it.
- Keyboard Parity: Focusing the trigger via keyboard navigation (
Tab) shows the tooltip; blurring it (Shift + Tabor clicking away) hides it. PressingEscapeshould also immediately dismiss it. - Screen Reader Parity: Assistive technologies must immediately recognize the association between the trigger element and the tooltip content without relying solely on visual cues.
Let’s start by defining our TypeScript interfaces for the component props.
import React, { ReactNode, useState, useRef, useEffect, useId } from 'react';
export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
export interface TooltipProps {
content: ReactNode;
children: ReactElement;
position?: TooltipPosition;
delay?: number;
}
Step 1: Handling Dual Triggers (Hover & Focus)
To capture both mouse and keyboard interactions seamlessly, we need to wrap our trigger element (the children) and attach event handlers to it. Instead of forcing the consumer to pass custom refs, we will clone the child element using React.cloneElement and inject our state management handlers.
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) clearTimeouts();
setIsVisible(false);
};
const clearTimeouts = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
// Cleanup timeouts on unmount
useEffect(() => {
return () => clearTimeouts();
}, []);
// Keyboard escape listener
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isVisible) {
hideTooltip();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isVisible]);
// ... continued below
};
By managing a configurable delay, we prevent the tooltip from flashing aggressively when users rapidly sweep their mouse across the interface.
Step 2: Wiring up ARIA DescribedBy
Accessibility relies heavily on semantic markup. Screen readers need to know that the focused or hovered element is described by the tooltip popup. We achieve this using the aria-describedby attribute.
We connect the trigger to the tooltip popup container using the unique ID generated by React’s useId() hook.
const triggerProps = {
onMouseEnter: showTooltip,
onMouseLeave: hideTooltip,
onFocus: showTooltip,
onBlur: hideTooltip,
'aria-describedby': isVisible ? tooltipId : undefined,
};
const clonedTrigger = React.cloneElement(children, { ...triggerProps });
When isVisible is true, aria-describedby points directly to the tooltip’s DOM node. When hidden, the attribute is omitted to keep the accessibility tree clean.
Step 3: Dynamic Positioning and Collision Detection
Absolute positioning based purely on CSS often fails when tooltips collide with the edges of the viewport. We need a calculation step that checks the bounding client rect of both the trigger and the tooltip popup, adjusting coordinates dynamically.
import React, { ReactElement, useState, useRef, useEffect, useId, useLayoutEffect } from 'react';
export const Tooltip: React.FC<TooltipProps> = ({
content,
children,
position = 'top',
delay = 200,
}) => {
const [isVisible, setIsVisible] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const triggerRef = useRef<HTMLElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
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);
timeoutRef.current = null;
}
setIsVisible(false);
};
useLayoutEffect(() => {
if (!isVisible || !triggerRef.current || !tooltipRef.current) return;
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
let top = 0;
let left = 0;
const spacing = 8;
switch (position) {
case 'top':
top = triggerRect.top - tooltipRect.height - spacing;
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
break;
case 'bottom':
top = triggerRect.bottom + spacing;
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
break;
case 'left':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.left - tooltipRect.width - spacing;
break;
case 'right':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.right + spacing;
break;
}
// Basic Viewport Collision Detection (Boundary clamping)
const padding = 12;
if (left < padding) {
left = padding;
} else if (left + tooltipRect.width > window.innerWidth - padding) {
left = window.innerWidth - tooltipRect.width - padding;
}
if (top < padding) {
// Fallback to bottom if top overflows viewport
top = triggerRect.bottom + spacing;
}
setCoords({ top: top + window.scrollY, left: left + window.scrollX });
}, [isVisible, position]);
// Attach refs properly to cloned element
const handleRef = (node: HTMLElement | null) => {
triggerRef.current = node;
const childRef = (children as any).ref;
if (typeof childRef === 'function') {
childRef(node);
} else if (childRef) {
childRef.current = node;
}
};
const triggerProps = {
ref: handleRef,
onMouseEnter: showTooltip,
onMouseLeave: hideTooltip,
onFocus: showTooltip,
onBlur: hideTooltip,
'aria-describedby': isVisible ? tooltipId : undefined,
};
const clonedTrigger = React.cloneElement(children, triggerProps);
return (
<>
{clonedTrigger}
{isVisible && (
<div
ref={tooltipRef}
id={tooltipId}
role="tooltip"
style={{
position: 'absolute',
top: `${coords.top}px`,
left: `${coords.left}px`,
zIndex: 1000,
}}
className="tooltip-popup"
>
{content}
</div>
)}
</>
);
};
Note on Ref Forwarding: When using
React.cloneElement, it is crucial to handle existing refs gracefully so that the consumer’s original ref isn’t overwritten. OurhandleRefhelper function bridges this gap effectively.
Step 4: Styling and Polish
To make our tooltip look sleek and modern, we apply simple CSS transitions for smooth entry and exit animations. We also use role="tooltip", which instructs screen readers to treat the element explicitly as a contextual popup.
.tooltip-popup {
background-color: #1e293b;
color: #f8fafc;
padding: 6px 12px;
border-radius: 6px;
font-size: 0.875rem;
line-height: 1.25;
pointer-events: none;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
white-space: nowrap;
opacity: 0;
animation: tooltipFadeIn 150ms ease-in-out forwards;
}
@keyframes tooltipFadeIn {
to {
opacity: 1;
}
}
Conclusion
Building an accessible React tooltip requires thinking beyond simple hover states. By combining:
- Dual-trigger event listeners for mouse and keyboard users,
- Dynamic coordinate calculation to prevent viewport overflow, and
- Semantic ARIA attributes (
aria-describedbyandrole="tooltip") for screen readers,
We ensure that our UI components remain inclusive, robust, and delightful to use for everyone.
You can easily extend this foundation further by introducing portals (ReactDOM.createPortal) to render tooltips directly into document roots, avoiding clipping issues inside overflow: hidden containers.