Hover, Focus, Announce: Crafting an Accessible Tooltip in React
Learn how to build a production-ready, fully accessible custom tooltip in React using TypeScript, handling robust mouse/keyboard focus states and proper ARIA wiring.
Hover, Focus, Announce: Crafting an Accessible Tooltip in React
Tooltips are ubiquitous in modern web interfaces. They provide supplementary context when users hover over an element. However, they are also one of the most frequently broken UI components when it comes to web accessibility (a11y).
A truly accessible tooltip must satisfy several conditions beyond simply appearing on mouseenter:
- Keyboard Parity: Users navigating via keyboard (
Tab) must trigger the tooltip onfocusand dismiss it onblurorEscape. - Screen Reader Announcements: Assistential technologies must automatically read the tooltip content via proper
aria-describedbywiring. - State Management: Hovering out, focusing away, or pressing escape must gracefully hide the tooltip without race conditions.
- Robust Positioning: The tooltip should never clip outside the viewport.
In this technical walkthrough, we will build a robust, accessible, custom Tooltip component in React using TypeScript and React portals.
The Anatomy of an Accessible Tooltip
Before diving into code, let’s examine the DOM relationship required by the W3C ARIA Authoring Practices Guide (APG) for tooltips:
- The trigger element receives
aria-describedby="[tooltip-id]". - The tooltip container receives
role="tooltip"and a matchingid. - The tooltip is not a child of the trigger element in the accessibility tree (often solved using React Portals).
Step 1: Defining the TypeScript Interfaces
Let’s start by defining our props. We want our component to accept a React node for the trigger, string or node content for the tooltip, and optional positioning configuration.
import React, { ReactNode, ReactElement } from 'react';
export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
export interface TooltipProps {
content: ReactNode;
children: ReactElement;
position?: TooltipPosition;
delay?: number;
}
Step 2: Building the Core Component Logic
Next, we implement the state management. We need to handle four primary interaction events:
onMouseEnter/onMouseLeavefor mouse users.onFocus/onBlurfor keyboard and screen reader users.onKeyDownto listen for theEscapekey, allowing users to dismiss the tooltip without moving focus.
import React, { useState, useRef, useId, cloneElement } from 'react';
import { createPortal } from 'react-dom';
import './Tooltip.css';
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);
const tooltipRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const tooltipId = useId();
const showTooltip = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
calculatePosition(rect, position);
setIsVisible(true);
}
}, delay);
};
const hideTooltip = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setIsVisible(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
hideTooltip();
}
};
const calculatePosition = (rect: DOMRect, pos: TooltipPosition) => {
// Basic positioning logic based on trigger bounding rect
let top = 0;
let left = 0;
const scrollY = window.scrollY;
const scrollX = window.scrollX;
switch (pos) {
case 'top':
top = rect.top + scrollY - 8;
left = rect.left + scrollX + rect.width / 2;
break;
case 'bottom':
top = rect.bottom + scrollY + 8;
left = rect.left + scrollX + rect.width / 2;
break;
case 'left':
top = rect.top + scrollY + rect.height / 2;
left = rect.left + scrollX - 8;
break;
case 'right':
top = rect.top + scrollY + rect.height / 2;
left = rect.right + scrollX + 8;
break;
}
setCoords({ top, left });
};
// Cloning child to inject accessibility attributes safely
const childProps = {
ref: triggerRef,
onMouseEnter: () => {
showTooltip();
children.props.onMouseEnter?.();
},
onMouseLeave: () => {
hideTooltip();
children.props.onMouseLeave?.();
},
onFocus: () => {
showTooltip();
children.props.onFocus?.();
},
onBlur: () => {
hideTooltip();
children.props.onBlur?.();
},
onKeyDown: (e: React.KeyboardEvent) => {
handleKeyDown(e);
children.props.onKeyDown?.(e);
},
'aria-describedby': isVisible ? tooltipId : undefined,
};
return (
<>
{cloneElement(children, childProps)}
{isVisible &&
createPortal(
<div
ref={tooltipRef}
id={tooltipId}
role="tooltip"
className={`tooltip tooltip-${position}`}
style={{
top: `${coords.top}px`,
left: `${coords.left}px`,
}}
>
{content}
</div>,
document.body
)}
</>
);
};
Step 3: Styling and Positioning Coordinates
To ensure the tooltip anchors properly (centered relative to the trigger element), we add corresponding CSS classes. Using CSS transforms allows us to easily offset the element from its calculated coordinate origin.
.tooltip {
position: absolute;
z-index: 9999;
padding: 6px 12px;
background-color: #1a1a1a;
color: #ffffff;
font-size: 0.875rem;
border-radius: 4px;
pointer-events: none;
white-space: nowrap;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: opacity 0.15s ease-in-out;
}
.tooltip-top {
transform: translate(-50%, -100%);
}
.tooltip-bottom {
transform: translate(-50%, 0);
}
.tooltip-left {
transform: translate(-100%, -50%);
}
.tooltip-right {
transform: translate(0, -50%);
}
Step 4: Testing Screen Reader Compatibility
When a screen reader user tabs onto the element wrapped by our Tooltip component, the browser reads out the element’s label followed immediately by the text contained inside the portal node linked via aria-describedby="[tooltipId]".
Usage Example
import React from 'react';
import { Tooltip } from './Tooltip';
export function App() {
return (
<div style={{ padding: '100px', textAlign: 'center' }}>
<Tooltip content="Deletes this item permanently" position="top">
<button className="delete-btn">
Delete Item
</button>
</Tooltip>
</div>
);
}
Best Practices & Edge Cases Handled
- Portal Rendering: By rendering the tooltip into
document.bodyviacreatePortal, we prevent clipping issues caused by parent containers withoverflow: hiddenor restrictivez-indexhierarchies. - Escape Hatch: Pressing the
Escapekey immediately closes the tooltip without forcing the user to move focus away from the interactive element. - Debounced Transitions: Using
setTimeoutprevents annoying flickering when a user rapidly moves their mouse across adjacent interactive items.
Conclusion
Building accessible UI components requires looking beyond the visual state. By combining React Portals, clean keyboard event interception, and precise ARIA attribute wiring, you can deliver a tooltip experience that feels delightful to mouse users and completely seamless to screen reader and keyboard-only users alike.