Hover and Focus: Building an Accessible Tooltip and Popover in React
A practical, step-by-step guide to building lightweight, accessible tooltips and popovers in React with full keyboard navigation, ARIA wiring, and zero third-party UI dependencies.
Hover and Focus: Building an Accessible Tooltip and Popover in React
Tooltips and popovers are ubiquitous UI patterns. We use tooltips to provide brief, supplementary descriptions for icons or truncated text, and popovers to present richer content—like menus, user profiles, or quick forms—on demand.
However, they are also among the most frequently mishandled components in web development. A naive implementation using a simple onMouseEnter / onMouseLeave state hook completely alienates keyboard-only users, screen reader operators, and mobile touch users.
In this guide, we will build fully accessible, lightweight Tooltip and Popover components in React using TypeScript. We will cover correct ARIA attributes, robust keyboard navigation, focus management, and graceful handling of touch devices—all without bloating our bundle with heavy third-party UI libraries.
The Core Requirements of Accessible Overlays
Before writing code, let’s establish what makes an overlay component accessible:
- Mouse and Keyboard Parity: If a tooltip or popover opens on hover, it must also open on keyboard focus.
- Dismissal via Escape: Pressing the
Escapekey must immediately close the overlay and return focus appropriately. - Semantic ARIA Wiring:
- Tooltips use
role="tooltip"linked viaaria-describedby. - Popovers use
aria-expandedand manage focus movement.
- Tooltips use
- Touch Device Resilience: Hover events do not map cleanly to touch screens; tapping should toggle state predictably.
1. Building the Accessible Tooltip Component
A tooltip provides contextual text for an element. It should appear on hover or focus, and disappear when the mouse leaves or the user presses Escape or moves focus away.
The TypeScript Implementation
import React, { useState, useRef, useId, cloneElement, ReactElement, useEffect } from 'react';
interface TooltipProps {
content: string;
children: ReactElement;
}
export const Tooltip: React.FC<TooltipProps> = ({ content, children }) => {
const [isVisible, setIsVisible] = useState(false);
const tooltipId = useId();
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const showTooltip = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setIsVisible(true);
};
const hideTooltip = () => {
timeoutRef.current = setTimeout(() => {
setIsVisible(false);
}, 100);
};
// Handle Escape key dismissal
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isVisible) {
setIsVisible(false);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isVisible]);
// Clone the trigger child to inject accessibility props
const trigger = cloneElement(children, {
onMouseEnter: (e: React.MouseEvent) => {
children.props.onMouseEnter?.(e);
showTooltip();
},
onMouseLeave: (e: React.MouseEvent) => {
children.props.onMouseLeave?.(e);
hideTooltip();
},
onFocus: (e: React.FocusEvent) => {
children.props.onFocus?.(e);
showTooltip();
},
onBlur: (e: React.FocusEvent) => {
children.props.onBlur?.(e);
hideTooltip();
},
'aria-describedby': isVisible ? tooltipId : undefined,
});
return (
<span className="tooltip-wrapper" style={{ position: 'relative', display: 'inline-block' }}>
{trigger}
{isVisible && (
<div
id={tooltipId}
role="tooltip"
className="tooltip-bubble"
style={{
position: 'absolute',
bottom: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginBottom: '6px',
padding: '4px 8px',
backgroundColor: '#1a1a1a',
color: '#fff',
borderRadius: '4px',
fontSize: '0.75rem',
whiteSpace: 'nowrap',
pointerEvents: 'none',
zIndex: 1000,
}}
>
{content}
</div>
)}
</span>
);
};
Why this works for accessibility:
aria-describedby: WhenisVisibleis true, the trigger element receives anaria-describedbyattribute pointing to the tooltip’s unique ID (useId()). Screen readers will automatically announce the tooltip text when the element receives focus.- Focus & Hover parity: By wiring both
onMouseEnter/onMouseLeaveandonFocus/onBlur, keyboard users tabbing through the page get the exact same visual cue and screen reader announcement as mouse users. - Escape Key handling: Global keyboard listeners ensure that hitting
Escapecleanly dismisses the tooltip without requiring the user to navigate away.
2. Building the Accessible Popover Component
While tooltips are read-only and transient, popovers contain interactive elements (like buttons, links, or inputs). This introduces a critical accessibility requirement: focus management.
When a popover opens, focus should ideally move inside the popover. When it closes, focus must return to the element that triggered it.
The Popover Implementation
import React, { useState, useRef, useId, useEffect, ReactElement, cloneElement } from 'react';
interface PopoverProps {
content: React.ReactNode;
children: ReactElement;
}
export const Popover: React.FC<PopoverProps> = ({ content, children }) => {
const [isOpen, setIsOpen] = useState(false);
const popoverId = useId();
const triggerRef = useRef<HTMLElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const togglePopover = () => setIsOpen((prev) => !prev);
// Handle Escape key and outside clicks
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen) {
setIsOpen(false);
triggerRef.current?.focus();
}
};
const handleClickOutside = (event: MouseEvent) => {
if (
isOpen &&
popoverRef.current &&
!popoverRef.current.contains(event.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(event.target as Node)
);
};
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Focus management when opening/closing
useEffect(() => {
if (isOpen) {
// Optionally focus the first focusable element inside the popover
const focusableElement = popoverRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusableElement?.focus();
}
}, [isOpen]);
const trigger = cloneElement(children, {
ref: (node: HTMLElement) => {
triggerRef.current = node;
// Preserve existing refs if passed
const { ref } = children;
if (typeof ref === 'function') ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;
},
onClick: (e: React.MouseEvent) => {
children.props.onClick?.(e);
togglePopover();
},
'aria-haspopup': 'dialog',
'aria-expanded': isOpen,
'aria-controls': popoverId,
});
return (
<div className="popover-container" style={{ position: 'relative', display: 'inline-block' }}>
{trigger}
{isOpen && (
<div
ref={popoverRef}
id={popoverId}
role="dialog"
tabIndex={-1}
style={{
position: 'absolute',
top: '100%',
left: '0',
marginTop: '8px',
padding: '16px',
backgroundColor: '#ffffff',
color: '#000000',
border: '1px solid #ccc',
borderRadius: '6px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
zIndex: 1000,
}}
>
{content}
</div>
)}
</div>
);
};
3. Key ARIA Attributes Explained
Getting the ARIA contract right ensures screen readers interpret the relationship between triggers and overlays correctly:
aria-haspopup="dialog": Informs assistive tech that activating this element will spawn a popover dialog container.aria-expanded={isOpen}: Dynamically communicates whether the popover is currently expanded (true) or collapsed (false). Screen readers announce this state change immediately upon activation.aria-controls={popoverId}: Binds the trigger directly to the DOM ID of the popover content container.role="dialog": Signals to screen reader users that the popover is a distinct modal or popup section containing interactive widgets.
4. Handling Touch Devices and Edge Cases
Mobile and touch screen interactions present unique challenges because touch devices lack a true “hover” state.
- Tap to Toggle: For tooltips on mobile, users often tap the element to trigger the tooltip, then tap elsewhere to dismiss it. Ensuring our click/blur handlers don’t instantly trap or misfire on touch screens is crucial.
- Viewport Boundary Detection: In production applications, hardcoding
top: 100%orbottom: 100%will cause overlays to clip off screen edges. While calculating exact bounding boxes manually can be complex, modern CSS offersanchor-nameandposition-anchor, and lightweight positioning libraries like@floating-ui/reactcan be layered on top of these accessible primitives if your design requires automated viewport flipping.
Conclusion
Building accessible components doesn’t require massive UI framework dependencies. By understanding core browser events, semantic ARIA attributes, and robust focus management techniques in React, you can create delightful, fully inclusive tooltips and popovers from scratch.
Summary Checklist for Your Overlays:
- Are triggers accessible via keyboard (
TabandEnter/Space)? - Do tooltips use
role="tooltip"andaria-describedby? - Do popovers use
aria-expanded,aria-haspopup, andaria-controls? - Does pressing
Escapedismiss the overlay and restore focus to the trigger? - Are focus rings visible and clearly styled for keyboard navigators?