Hover, Focus, Announce: Crafting an Accessible Tooltip Component in React
Learn how to build a production-ready, highly accessible tooltip component in React from scratch with zero third-party dependencies, covering dual-state hover/focus triggers, robust ARIA attributes, and smart floating positioning.
Hover, Focus, Announce: Crafting an Accessible Tooltip Component in React
At first glance, a tooltip appears to be one of the simplest patterns in UI engineering: hover over an element, show a small box of text, move away, hide it. However, when we dissect this pattern through the lens of robust web accessibility, cross-device interaction, and resilient state management, building a truly production-grade tooltip becomes a fascinating engineering challenge.
Most developers rely on heavy third-party UI libraries for tooltips. But what happens when you need strict bundle-size control, custom design system constraints, or guaranteed compliance with WCAG 2.1 Success Criterion 1.4.13 (Content on Hover or Focus)?
In this guide, we will build a production-ready, fully accessible tooltip component in React and TypeScript from scratch—with zero third-party dependencies. We will solve the architectural hurdles of mouse vs. keyboard interactions, manage timing delays, correctly wire up aria-describedby and role="tooltip", and implement intelligent viewport-aware floating positioning.
The Architectural Challenges of Tooltips
Before writing code, we must understand the core user experience and accessibility requirements that govern tooltips:
- Dual Interaction Modes: A tooltip must appear both on mouse hover over the trigger and when the trigger receives keyboard focus (e.g., via the
Tabkey). - Dismissal Mechanics: Users must be able to dismiss the tooltip using the
Escapekey without moving focus away from the trigger element. - Hover Persistence (WCAG 1.4.13): If a user moves their mouse pointer from the trigger onto the tooltip itself, the tooltip must not disappear. The user must be able to hover over the tooltip content.
- Screen Reader Announcements: Screen readers should seamlessly associate the trigger with the tooltip text using native HTML semantics (
aria-describedby) rather than relying solely on brittle live regions, while ensuring immediate clarity. - Viewport Boundaries: Tooltips must never clip off the edge of the screen, regardless of where the trigger sits in the DOM.
Let’s break down how we will address these challenges using modern React hooks and native browser APIs.
Component Anatomy & State Architecture
Our implementation will use a compound component structure (Tooltip.Root, Tooltip.Trigger, Tooltip.Content) or a simplified wrapper component depending on API preference. For maximum ergonomics, we’ll design a flexible wrapper that handles state internally while exposing clean render props or direct children.
Let’s start by defining our TypeScript interfaces.
import React, {
useState,
useRef,
useEffect,
useId,
cloneElement,
isValidElement,
HTMLAttributes,
ReactNode,
} from 'react';
type Placement = 'top' | 'bottom' | 'left' | 'right';
export interface TooltipProps {
content: ReactNode;
placement?: Placement;
delayShow?: number;
delayHide?: number;
children: React.ReactElement;
}
Managing Interaction States and Timers
A common issue with tooltips is “flickering”—accidental mouse fly-bys triggering instant layout shifts. We need configurable show and hide delays (delayShow and delayHide). Furthermore, because we need to support hovering over the tooltip content itself, our timer logic must track whether the mouse has entered the tooltip container.
export const Tooltip: React.FC<TooltipProps> = ({
content,
placement = 'top',
delayShow = 200,
delayHide = 150,
children,
}) => {
const [isVisible, setIsVisible] = useState(false);
const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
const triggerRef = useRef<HTMLElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const showTimer = useRef<NodeJS.Timeout | null>(null);
const hideTimer = useRef<NodeJS.Timeout | null>(null);
const tooltipId = useId();
const clearTimers = () => {
if (showTimer.current) clearTimeout(showTimer.current);
if (hideTimer.current) clearTimeout(hideTimer.current);
};
const handleShow = () => {
clearTimers();
showTimer.current = setTimeout(() => {
setIsVisible(true);
}, delayShow);
};
const handleHide = () => {
clearTimers();
hideTimer.current = setTimeout(() => {
setIsVisible(false);
}, delayHide);
};
// ... positioning and event handlers go here
};
Handling Pointer vs. Keyboard States
To ensure our tooltip behaves correctly across input modalities, we hook into both mouse events (onMouseEnter, onMouseLeave) and focus events (onFocus, onBlur). Additionally, we must listen for the Escape key to dismiss the tooltip immediately.
// Keyboard Escape listener
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isVisible) {
setIsVisible(false);
clearTimers();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isVisible]);
When bridging these handlers to the trigger element, we clone the child element to inject accessibility attributes like aria-describedby dynamically.
const triggerProps = {
ref: (node: HTMLElement | null) => {
triggerRef.current = node;
// Preserve existing refs if passed by child
const childRef = (children as any).ref;
if (typeof childRef === 'function') childRef(node);
else if (childRef) childRef.current = node;
},
'aria-describedby': isVisible ? tooltipId : undefined,
onMouseEnter: () => {
handleShow();
children.props.onMouseEnter?.();
},
onMouseLeave: () => {
handleHide();
children.props.onMouseLeave?.();
},
onFocus: () => {
handleShow();
children.props.onFocus?.();
},
onBlur: () => {
handleHide();
children.props.onBlur?.();
},
};
const enhancedTrigger = cloneElement(children, triggerProps);
Accessibility Architecture: ARIA Semantics
Accessibility hinges on two primary native mechanisms:
aria-describedby: Placed on the interactive trigger element, pointing directly to theidof the tooltip container (tooltipId). This instructs screen readers to read the text contents of the tooltip immediately after announcing the name and role of the trigger element.role="tooltip": Placed on the floating popup element itself so assistive technologies recognize its semantic purpose.
<div
ref={tooltipRef}
id={tooltipId}
role="tooltip"
aria-hidden={!isVisible}
onMouseEnter={clearTimers} // Keeps tooltip open when hovered
onMouseLeave={handleHide} // Hides tooltip when mouse leaves the tooltip body
style={{
position: 'absolute',
top: `${coords.top}px`,
left: `${coords.left}px`,
visibility: isVisible ? 'visible' : 'hidden',
opacity: isVisible ? 1 : 0,
transition: 'opacity 150ms ease-in-out',
zIndex: 1000,
}}
>
{content}
</div>
Crucial WCAG Compliance Note: By attaching
onMouseEnter={clearTimers}andonMouseLeave={handleHide}directly to the tooltip container, we satisfy WCAG 1.4.13. If a user moves their mouse from the button onto the tooltip popup,handleHideis cancelled, allowing them to read or interact with the tooltip content without it abruptly vanishing.
Floating Positioning Engine
Calculating coordinates dynamically without external libraries like Floating UI or Popper.js requires reading bounding client rectangles (getBoundingClientRect) and applying basic offset math.
const updatePosition = () => {
if (!triggerRef.current || !tooltipRef.current) return;
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const scrollX = window.scrollX;
const scrollY = window.scrollY;
let top = 0;
let left = 0;
const spacing = 8;
switch (placement) {
case 'top':
top = triggerRect.top + scrollY - tooltipRect.height - spacing;
left = triggerRect.left + scrollX + (triggerRect.width - tooltipRect.width) / 2;
break;
case 'bottom':
top = triggerRect.bottom + scrollY + spacing;
left = triggerRect.left + scrollX + (triggerRect.width - tooltipRect.width) / 2;
break;
case 'left':
top = triggerRect.top + scrollY + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.left + scrollX - tooltipRect.width - spacing;
break;
case 'right':
top = triggerRect.top + scrollY + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.right + scrollX + spacing;
break;
}
setCoords({ top, left });
};
useEffect(() => {
if (isVisible) {
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}
}, [isVisible, placement]);
The Complete Production-Ready Component
Combining everything into a single, cohesive file gives us a robust, zero-dependency, accessible tooltip component ready for production design systems.
import React, {
useState,
useRef,
useEffect,
useId,
cloneElement,
ReactNode,
} from 'react';
type Placement = 'top' | 'bottom' | 'left' | 'right';
export interface TooltipProps {
content: ReactNode;
placement?: Placement;
delayShow?: number;
delayHide?: number;
children: React.ReactElement;
}
export const Tooltip: React.FC<TooltipProps> = ({
content,
placement = 'top',
delayShow = 200,
delayHide = 150,
children,
}) => {
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 showTimer = useRef<NodeJS.Timeout | null>(null);
const hideTimer = useRef<NodeJS.Timeout | null>(null);
const tooltipId = useId();
const clearTimers = () => {
if (showTimer.current) clearTimeout(showTimer.current);
if (hideTimer.current) clearTimeout(hideTimer.current);
};
const handleShow = () => {
clearTimers();
showTimer.current = setTimeout(() => setIsVisible(true), delayShow);
};
const handleHide = () => {
clearTimers();
hideTimer.current = setTimeout(() => setIsVisible(false), delayHide);
};
const updatePosition = () => {
if (!triggerRef.current || !tooltipRef.current) return;
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const scrollX = window.scrollX;
const scrollY = window.scrollY;
const spacing = 8;
let top = 0;
let left = 0;
switch (placement) {
case 'top':
top = triggerRect.top + scrollY - tooltipRect.height - spacing;
left = triggerRect.left + scrollX + (triggerRect.width - tooltipRect.width) / 2;
break;
case 'bottom':
top = triggerRect.bottom + scrollY + spacing;
left = triggerRect.left + scrollX + (triggerRect.width - tooltipRect.width) / 2;
break;
case 'left':
top = triggerRect.top + scrollY + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.left + scrollX - tooltipRect.width - spacing;
break;
case 'right':
top = triggerRect.top + scrollY + (triggerRect.height - tooltipRect.height) / 2;
left = triggerRect.right + scrollX + spacing;
break;
}
setCoords({ top, left });
};
useEffect(() => {
if (isVisible) {
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}
}, [isVisible, placement]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isVisible) {
setIsVisible(false);
clearTimers();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isVisible]);
const triggerProps = {
ref: (node: HTMLElement | null) => {
triggerRef.current = node;
const childRef = (children as any).ref;
if (typeof childRef === 'function') childRef(node);
else if (childRef) childRef.current = node;
},
'aria-describedby': isVisible ? tooltipId : undefined,
onMouseEnter: () => {
handleShow();
children.props.onMouseEnter?.();
},
onMouseLeave: () => {
handleHide();
children.props.onMouseLeave?.();
},
onFocus: () => {
handleShow();
children.props.onFocus?.();
},
onBlur: () => {
handleHide();
children.props.onBlur?.();
},
};
const enhancedTrigger = cloneElement(children, triggerProps);
return (
<>
{enhancedTrigger}
<div
ref={tooltipRef}
id={tooltipId}
role="tooltip"
aria-hidden={!isVisible}
onMouseEnter={clearTimers}
onMouseLeave={handleHide}
style={{
position: 'absolute',
top: `${coords.top}px`,
left: `${coords.left}px`,
visibility: isVisible ? 'visible' : 'hidden',
opacity: isVisible ? 1 : 0,
transition: 'opacity 150ms ease-in-out',
backgroundColor: '#1f2937',
color: '#ffffff',
padding: '6px 12px',
borderRadius: '4px',
fontSize: '0.875rem',
pointerEvents: isVisible ? 'auto' : 'none',
zIndex: 9999,
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
}}
>
{content}
</div>
</>
);
};
Conclusion
Building accessible user interface components requires looking past standard happy-path rendering. By combining robust focus management, dynamic ARIA labeling via aria-describedby, strict adherence to WCAG hover persistence rules, and clean geometry calculations, we’ve created a tooltip component that is lightweight, performant, and fully accessible to screen reader and keyboard-only users alike.
Drop this component into your project, style the container with your design system tokens, and enjoy total control over your UI architecture without bloated third-party dependencies!