Hover and Hide: Building an Accessible Tooltip Component in React from Scratch
Learn how to build a production-grade, fully accessible tooltip component in React with proper ARIA attributes, keyboard support, and dynamic positioning.
Hover and Hide: Building an Accessible Tooltip Component in React from Scratch
Tooltips are among the most ubiquitous patterns in modern web UI. They provide helpful, contextual hints when users hover over or focus on an element. Yet, despite their simplicity, they are frequently implemented incorrectly—breaking screen readers, trapping keyboard users, or vanishing when the user tries to interact with them.
In this post, we will build a production-grade, fully accessible tooltip component in React and TypeScript from scratch. We will handle aria-describedby relationships, manage dual trigger states (hover and focus), implement keyboard escape dismissal, and calculate dynamic viewport positioning without relying on heavy third-party libraries like Popper.js or Floating UI.
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):
- Semantic Connection: The trigger element must reference the tooltip container via
aria-describedby, linking their IDs so screen readers announce the description when the trigger receives focus or is hovered. - State Management: The tooltip must appear on mouse hover and keyboard focus, and disappear on mouse leave, keyboard blur, or when the user presses the
Escapekey. - Non-Interactive Nature: Standard tooltips are static informational containers. They should not receive keyboard focus themselves; focus must remain on the trigger.
- No Visual Clipping: The tooltip must dynamically adjust its position so it never overflows the visible viewport boundaries.
Step 1: Defining the Types and State
Let’s start by defining our TypeScript interface. Our Tooltip component will accept a label (the text to display), a placement preference, and standard children.
import React, { useState, useRef, useEffect, ReactNode, cloneElement } from 'react';
type Placement = 'top' | 'bottom' | 'left' | 'right';
interface TooltipProps {
content: ReactNode;
placement?: Placement;
children: React.ReactElement;
delay?: number;
}
Next, inside the component, we need state for tracking visibility and a unique ID to satisfy the aria-describedby contract.
export const Tooltip: React.FC<TooltipProps> = ({
content,
placement = 'top',
children,
delay = 200,
}) => {
const [isVisible, setIsVisible] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const triggerRef = useRef<HTMLElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const tooltipId = useRef(`tooltip-${Math.random().toString(36.substring(2, 9))}`).current;
// Implementation details follow...
};
Step 2: Managing Triggers and Keyboard Dismissal
A robust tooltip must respond to both mouse and keyboard interactions seamlessly. We also need to listen for the Escape key to instantly dismiss the tooltip if it’s open.
const showTooltip = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setIsVisible(true);
}, delay);
};
const hideTooltip = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setIsVisible(false);
};
// Handle Escape key dismissal
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isVisible) {
hideTooltip();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isVisible]);
By attaching showTooltip to onMouseEnter and onFocus, and hideTooltip to onMouseLeave and onBlur, we cover both mouse users and keyboard tab-navigation users uniformly.
Step 3: Dynamic Viewport Positioning
Calculating coordinates manually gives us lightweight control over positioning without adding heavy dependencies. When the tooltip becomes visible, we measure the bounding client rectangles of both the trigger element and the tooltip container.
useEffect(() => {
if (!isVisible) return;
const trigger = triggerRef.current;
const tooltip = tooltipRef.current;
if (!trigger || !tooltip) return;
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
let top = 0;
let left = 0;
const gap = 8; // Distance between trigger and tooltip
switch (placement) {
case 'top':
top = triggerRect.top - tooltipRect.height - gap;
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
break;
case 'bottom':
top = triggerRect.bottom + gap;
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
break;
case 'left':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.left - tooltipRect.width - gap;
break;
case 'right':
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.right + gap;
break;
}
// Basic viewport boundary correction
const padding = 12;
if (left < padding) {
left = padding;
} else if (left + tooltipRect.width > window.innerWidth - padding) {
left = window.innerWidth - tooltipRect.width - padding;
}
setCoords({ top: top + window.scrollY, left: left + window.scrollX });
}, [isVisible, placement]);
Step 4: Assembling the Component and Cloning Children
To inject the required accessibility attributes (aria-describedby) and event handlers directly into the user’s child element without forcing an unnecessary wrapper div into the DOM, we can use React’s cloneElement utility.
const clonedChild = 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;
},
'aria-describedby': isVisible ? tooltipId : undefined,
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();
},
});
return (
<>
{clonedChild}
{isVisible && (
<div
ref={tooltipRef}
id={tooltipId}
role="tooltip"
style={{
position: 'absolute',
top: `${coords.top}px`,
left: `${coords.left}px`,
zIndex: 1000,
}}
className="tooltip-container"
>
{content}
</div>
)}
</>
);
};
Step 5: Polishing with CSS
A touch of CSS ensures smooth transitions and clean visual presentation.
.tooltip-container {
background-color: #1f2937;
color: #f9fafb;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
border-radius: 0.375rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
pointer-events: none;
white-space: nowrap;
animation: tooltip-fadein 150ms ease-out;
}
@keyframes tooltip-fadein {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
Conclusion
By taking the time to implement proper ARIA roles, dual event triggers, viewport boundary checking, and keyboard dismissal via the Escape key, we’ve created a bulletproof tooltip component. It respects screen readers, behaves predictably for keyboard-only navigators, and maintains high performance without heavy UI libraries.
You can extend this foundation further by adding collision detection for flipping placements automatically when hitting viewport edges, or by supporting rich HTML content. Happy coding!