Building an Accessible Tooltip in React: Hover, Focus, and Viewport Awareness
A practical, production-ready guide to building an accessible React tooltip component supporting mouse and keyboard interactions, robust ARIA attributes, and intelligent viewport positioning.
Building an Accessible Tooltip in React: Hover, Focus, and Viewport Awareness
Tooltips are one of the most common UI patterns on the web, yet they are frequently broken. If you have ever hovered over an icon only to find the explanation disappears when you try to read it, or navigated a site via keyboard and realized tooltips never appear on focus, you have experienced inaccessible tooltips.
A truly production-ready tooltip must satisfy several non-trivial requirements:
- Equal Keyboard & Mouse Support: It must appear on both hover and keyboard focus.
- Proper ARIA Semantics: Screen readers must announce the tooltip content automatically using
aria-describedby. - Escape Hatch: Pressing the
Escapekey should immediately dismiss the tooltip. - Viewport Awareness: It must never clip off the edges of the browser window.
In this guide, we will build a robust, accessible tooltip component in React and TypeScript from scratch using clean custom hooks and modern DOM measurements.
The Architecture of an Accessible Tooltip
To manage state cleanly without prop-drilling, we will split our logic into a custom hook (useTooltip) and a compound component structure (TooltipProvider, TooltipTrigger, and TooltipContent).
Here is what our component API will look like:
<Tooltip>
<TooltipTrigger>
<button>Delete Account</button>
</TooltipTrigger>
<TooltipContent>
This action cannot be undone.
</TooltipContent>
</Tooltip>
1. State Management & Accessibility Hooks
First, let’s create our custom hook. We need to track whether the tooltip is open, generate a unique ID for ARIA association, and manage enter/leave/focus/blur/escape events.
import { useState, useRef, useId, useCallback, useEffect } from 'react';
export interface UseTooltipProps {
delay?: number;
}
export function useTooltip({ delay = 200 }: UseTooltipProps = {}) {
const [isOpen, setIsOpen] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const id = useId();
const tooltipId = `tooltip-${id}`;
const show = useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setIsOpen(true);
}, delay);
}, [delay]);
const hide = useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setIsOpen(false);
}, []);
// Handle Escape key dismissal
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape' && isOpen) {
hide();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, hide]);
return {
isOpen,
show,
hide,
tooltipId,
};
}
2. Crafting the Trigger Component
The trigger is responsible for binding mouse events (onMouseEnter, onMouseLeave), focus events (onFocus, onBlur), and most importantly, the aria-describedby attribute.
import React, { cloneElement, isValidElement } from 'react';
interface TooltipTriggerProps {
children: React.ReactNode;
isOpen: boolean;
show: () => void;
hide: () => void;
tooltipId: string;
}
export const TooltipTrigger: React.FC<TooltipTriggerProps> = ({
children,
isOpen,
show,
hide,
tooltipId,
}) => {
if (!isValidElement(children)) {
throw new Error('TooltipTrigger must contain a single valid React element.');
}
return cloneElement(children, {
'aria-describedby': isOpen ? tooltipId : undefined,
onMouseEnter: (e: React.MouseEvent) => {
children.props.onMouseEnter?.(e);
show();
},
onMouseLeave: (e: React.MouseEvent) => {
children.props.onMouseLeave?.(e);
hide();
},
onFocus: (e: React.FocusEvent) => {
children.props.onFocus?.(e);
show();
},
onBlur: (e: React.FocusEvent) => {
children.props.onBlur?.(e);
hide();
},
});
};
Why
aria-describedby? When a screen reader user focuses on the trigger element, the browser reads the element’s label and immediately follows up by reading the text contained within the element referenced byaria-describedby. This provides seamless contextual help.
Solving Viewport Clipping
Tooltips often break when they render near the edges of the screen. If a button is flush against the right side of the viewport, a standard absolute positioning strategy will cause the tooltip to overflow off-screen, creating horizontal scrollbars or hiding content.
To solve this, we measure the trigger element’s bounding client rect and dynamically calculate whether the tooltip fits above, below, to the left, or to the right.
import React, { useRef, useEffect, useState } from 'react';
interface TooltipContentProps {
children: React.ReactNode;
isOpen: boolean;
tooltipId: string;
}
export const TooltipContent: React.FC<TooltipContentProps> = ({
children,
isOpen,
tooltipId,
}) => {
const contentRef = useRef<HTMLDivElement>(null);
const [coords, setCoords] = useState({ top: 0, left: 0 });
useEffect(() => {
if (!isOpen) return;
const trigger = document.querySelector(`[aria-describedby="${tooltipId}"]`);
if (!trigger || !contentRef.current) return;
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = contentRef.current.getBoundingClientRect();
const spacing = 8;
// Default: Position above the trigger, centered horizontally
let top = triggerRect.top - tooltipRect.height - spacing;
let left = triggerRect.left + (triggerRect.width / 2) - (tooltipRect.width / 2);
// Viewport Boundary Checks
const padding = 12;
// If it clips top, flip it below the trigger
if (top < padding) {
top = triggerRect.bottom + spacing;
}
// If it clips left
if (left < padding) {
left = padding;
}
// If it clips right
if (left + tooltipRect.width > window.innerWidth - padding) {
left = window.innerWidth - tooltipRect.width - padding;
}
setCoords({ top: top + window.scrollY, left: left + window.scrollX });
}, [isOpen, tooltipId]);
if (!isOpen) return null;
return (
<div
ref={contentRef}
id={tooltipId}
role="tooltip"
style={{
position: 'absolute',
top: `${coords.top}px`,
left: `${coords.left}px`,
zIndex,
}}
className="tooltip-bubble"
>
{children}
</div>
);
};
Putting It All Together
Now we can assemble our top-level <Tooltip /> container component to tie the hooks, triggers, and content elements together cleanly.
import React from 'react';
import { useTooltip, UseTooltipProps } from './useTooltip';
import { TooltipTrigger } from './TooltipTrigger';
import { TooltipContent } from './TooltipContent';
interface TooltipProps extends UseTooltipProps {
children: [React.ReactElement, React.ReactElement];
}
export const Tooltip: React.FC<TooltipProps> = ({ children, delay }) => {
const { isOpen, show, hide, tooltipId } = useTooltip({ delay });
const [trigger, content] = children;
return (
<>
<TooltipTrigger
isOpen={isOpen}
show={show}
hide={hide}
tooltipId={tooltipId}
>
{trigger}
</TooltipTrigger>
<TooltipContent
isOpen={isOpen}
tooltipId={tooltipId}
>
{content}
</TooltipContent>
</>
);
};
Styling for Production
To ensure your tooltip looks polished and transitions smoothly, combine your dynamic coordinates with clean CSS:
.tooltip-bubble {
background-color: #1e293b;
color: #f8fafc;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
line-height: 1.25;
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; /* Prevents tooltip from interfering with mouse events */
max-width: 240px;
word-wrap: break-word;
animation: tooltipFadeIn 150ms ease-out;
}
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
Conclusion
Building accessible design system components requires attention to detail beyond simple visual styling. By implementing native keyboard focus tracking, correct aria-describedby relationships, Escape key dismissal, and viewport boundary detection, you ensure that all users—regardless of whether they use a mouse, screen reader, or keyboard—can comfortably navigate your application.