Building an Accessible Tooltip in React: Hover, Focus, and ARIA DescribedBy Patterns
A deep dive into building a robust, production-ready tooltip in React from scratch, covering pointer vs. keyboard focus, delayed appearance, positioning flip-logic, and aria-describedby wiring.
Building an Accessible Tooltip in React: Hover, Focus, and ARIA DescribedBy Patterns
Tooltips are among the most common UI patterns on the web. They provide supplementary, contextual information when a user hovers over or focuses on an element. Yet, despite their ubiquity, they are frequently implemented incorrectly—breaking screen readers, failing keyboard navigation, or disappearing unexpectedly when the user tries to read them.
In this deep dive, we will build a production-ready, fully accessible tooltip component in React and TypeScript from scratch. We won’t rely on third-party modal libraries; instead, we will carefully orchestrate mouse events, keyboard focus, screen reader announcements via aria-describedby, intelligent positioning, and robust enter/leave delay logic.
The Anatomy of an Accessible Tooltip
Before writing code, let’s establish what makes a tooltip accessible according to the WAI-ARIA Authoring Practices Guide (APG):
- Trigger and Popup Relationship: The trigger element must reference the tooltip content via
aria-describedby(oraria-labelledby, depending on the pattern, thoughdescribedbyis standard for supplementary descriptions). - Visibility State: When hidden, the tooltip must be completely removed from the DOM or hidden via
display: none/visibility: hiddencombined witharia-hidden="true"so screen readers do not read it prematurely. - Keyboard Accessibility: Tabbing to the trigger element must display the tooltip just as hovering with a mouse does.
- Dismissal: Pressing
Escapemust dismiss the tooltip immediately without moving focus away from the trigger. - Hover Intent: Moving the mouse from the trigger to the tooltip body should not cause the tooltip to instantly vanish.
Designing the React Component API
Let’s design a clean, compound component or wrapper-based API. We want a <Tooltip> wrapper that accepts the tooltip content as a prop and wraps a single interactive child element.
import React, { useState, useRef, useEffect, ReactElement, cloneElement, useId } from 'react';
interface TooltipProps {
content: React.ReactNode;
children: ReactElement;
delay?: number;
position?: 'top' | 'bottom' | 'left' | 'right';
}
Why Cloning Children is Necessary Here
To wire up aria-describedby, onMouseEnter, onFocus, and other event handlers automatically without forcing the consumer to manually spread props, we intercept the child element using React.cloneElement. This keeps the consumer’s JSX clean and semantic.
Implementing State, Timers, and ARIA Wiring
Let’s construct the core component logic. We need state to track visibility, unique IDs for ARIA associations, and refs to handle cleanup of hover delays.
export const Tooltip: React.FC<TooltipProps> = ({
content,
children,
delay = 300,
position = 'top',
}) => {
const [isVisible, setIsVisible] = useState<boolean>(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const tooltipId = useId();
// Clear any pending timeouts on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
const showTooltip = (withDelay: boolean = true) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (withDelay && delay > 0) {
timeoutRef.current = setTimeout(() => {
setIsVisible(true);
}, delay);
} else {
setIsVisible(true);
}
};
const hideTooltip = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setIsVisible(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
hideTooltip();
}
};
// Extract existing handlers from child if present
const child = React.Children.only(children);
const triggerProps = {
'aria-describedby': isVisible ? tooltipId : undefined,
onMouseEnter: () => showTooltip(true),
onMouseLeave: () => hideTooltip(),
onFocus: () => showTooltip(false), // Instant show on keyboard focus
onBlur: () => hideTooltip(),
onKeyDown: (e: React.KeyboardEvent) => {
child.props.onKeyDown?.(e);
handleKeyDown(e);
},
};
return (
<div className="tooltip-wrapper" style={{ position: 'relative', display: 'inline-block' }}>
{cloneElement(child, triggerProps)}
{isVisible && (
<div
id={tooltipId}
role="tooltip"
className={`tooltip-bubble tooltip-${position}`}
>
{content}
</div>
)}
</div>
);
};
Key Takeaways from the Logic:
onFocusis Instant: Screen reader users and keyboard navigators expect immediate feedback when focusing an element. We passfalsetoshowTooltipto bypass the hover delay.- Dynamic
aria-describedby: The ID is only attached when the tooltip is active, preventing screen readers from constantly interrupting standard reading flows with stale descriptions. - Escape Key Handling: Pressing
Escapewhile the tooltip is focused/active instantly dismisses it.
Handling Edge-to-Edge Overflow (Flip Logic)
A common issue with basic absolute positioning (top: -40px) is that tooltips get cut off by viewport edges if the trigger is near the top or sides of the screen.
To build a robust positioning engine, we can measure the DOM nodes using useLayoutEffect and compute whether the tooltip overflows the current viewport boundaries.
import React, { useState, useRef, useLayoutEffect, ReactElement, cloneElement, useId } from 'react';
// Inside the component:
const tooltipRef = useRef<HTMLDivElement>(null);
const [actualPosition, setActualPosition] = useState(position);
useLayoutEffect(() => {
if (isVisible && tooltipRef.current) {
const rect = tooltipRef.current.getBoundingClientRect();
const overflowTop = rect.top < 0;
const overflowBottom = rect.bottom > window.innerHeight;
const overflowLeft = rect.left < 0;
const overflowRight = rect.right > window.innerWidth;
if (position === 'top' && overflowTop) {
setActualPosition('bottom');
} else if (position === 'bottom' && overflowBottom) {
setActualPosition('top');
} else if (position === 'left' && overflowLeft) {
setActualPosition('right');
} else if (position === 'right' && overflowRight) {
setActualPosition('left');
} else {
setActualPosition(position);
}
}
}, [isVisible, position]);
This dynamic adjustment guarantees that even if a developer specifies position="top" on an element pinned to the top of the viewport, the component will intelligently flip to bottom.
Styling and Animation with CSS
To make our tooltip feel polished, we pair our React state with smooth opacity transitions. We also ensure that pointer events on the tooltip bubble don’t block interaction.
.tooltip-wrapper {
position: relative;
display: inline-block;
}
.tooltip-bubble {
position: absolute;
zIndex: 1000;
padding: 6px 12px;
font-size: 0.875rem;
line-height: 1.25;
color: #ffffff;
background-color: #1f2937;
border-radius: 4px;
white-space: nowrap;
pointer-events: none; /* Crucial so mouse events don't jitter */
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
animation: tooltipFadeIn 150ms ease-out;
}
/* Positioning modifiers */
.tooltip-top {
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
}
.tooltip-bottom {
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
}
.tooltip-left {
right: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
}
.tooltip-right {
left: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
}
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: translateX(-50%) scale(0.95);
}
to {
opacity: 1;
transform: translateX(-50%) scale(1);
}
}
Putting It All Together in Usage
Here is how clean and accessible our component usage looks in practice:
import React from 'react';
import { Tooltip } from './Tooltip';
export default function App() {
return (
<div style={{ padding: '100px', textAlign: 'center' }}>
<Tooltip content="This is a fully accessible tooltip!" position="top">
<button
onClick={() => alert('Button clicked!')}
style={{
padding: '10px 20px',
fontSize: '1rem',
cursor: 'pointer',
}}
>
Hover or Focus Me
</button>
</Tooltip>
</div>
);
}
Checklist for Production Tooltips
Before deploying your custom tooltip component to production, verify the following points:
- Screen Reader Test: Navigate with VoiceOver (macOS) or NVDA (Windows). Focus the trigger element and confirm the tooltip text is read out cleanly.
- Keyboard Navigation: Tab to the element, verify the tooltip appears instantly, press
Escape, and verify it disappears without losing focus. - No Focus Loss: Ensure the tooltip container itself does not steal focus from the trigger element.
- Touch Device Graceful Degradation: Keep in mind that mobile touch screens do not have “hover”. Ensure critical information is never locked exclusively inside a tooltip.
By combining precise event handling, aria-describedby associations, and intelligent positioning logic, you create an inclusive experience that satisfies both sighted mouse users and assistive technology users alike.