Building an Accessible Tooltip in React: Positioning, ARIA DescribedBy, and Hover/Focus Parity
A practical guide to building an accessible React tooltip component using floating-ui, ensuring precise positioning, full keyboard accessibility, and correct screen reader announcements via aria-describedby.
Building an Accessible Tooltip in React: Positioning, ARIA DescribedBy, and Hover/Focus Parity
Tooltips are ubiquitous in modern user interfaces. They provide supplemental context, formatting shortcuts, or definitions when space is at a premium. However, they are also one of the most frequently mishandled UI patterns from an accessibility standpoint.
Common pitfalls include:
- Hover-only states: Mouse users get the information, but keyboard users relying on focus rings are left in the dark.
- Broken screen reader announcements: Screen readers ignore the tooltip because it lacks proper
aria-describedbyorrole="tooltip"associations. - Viewport clipping: Custom CSS absolute positioning fails when the trigger is near the edge of the screen, hiding the tooltip.
In this guide, we will build a production-grade, fully accessible Tooltip component in React using TypeScript and Floating UI. We will cover precise boundary-aware positioning, hover-and-focus parity, and robust screen reader support.
The Anatomy of an Accessible Tooltip
Before diving into code, let’s establish the behavioral contract of an accessible tooltip according to the WAI-ARIA Authoring Practices Guide (APG):
- Trigger Parity: The tooltip must appear on both mouse hover (
mouseenter/mouseleave) and keyboard focus (focus/blur). - Dismissal: Pressing the
Escapekey must hide the tooltip immediately without shifting focus away from the trigger. - DOM Association: The trigger element must reference the tooltip container using
aria-describedby. - Role: The tooltip element must carry
role="tooltip". - No Interactivity: Standard tooltips are non-interactive. The user should not be able to move focus inside the tooltip itself (if you need interactive content, you should build a Popover instead).
Setting Up the Dependencies
We will use '@floating-ui/react' for positioning calculations, collision detection, and interaction hooks.
npm install @floating-ui/react
Floating UI handles the complex math required to keep our tooltip inside the viewport, flipping it to the opposite side if it overflows, and shifting it along edges when necessary.
Building the Tooltip Component
Let’s implement our <Tooltip> component. We will encapsulate the state, event listeners, and accessibility attributes cleanly using Floating UI’s hooks.
import React, { useState } from 'react';
import {
useFloating,
useInteractions,
useHover,
useFocus,
useDismiss,
useRole,
offset,
flip,
shift,
arrow,
FloatingArrow,
safePolygon,
} from '@floating-ui/react';
interface TooltipProps {
label: string;
children: React.ReactElement;
placement?: 'top' | 'bottom' | 'left' | 'right';
}
export const Tooltip: React.FC<TooltipProps> = ({
label,
children,
placement = 'top',
}) => {
const [isOpen, setIsOpen] = useState(false);
const arrowRef = React.useRef(null);
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement,
middleware: [
offset(8),
flip(),
shift({ padding: 8 }),
arrow({ element: arrowRef }),
],
});
// Interaction hooks
const hover = useHover(context, {
move: false,
handleClose: safePolygon()
});
const focus = useFocus(context);
const dismiss = useDismiss(context, {
// Dismiss on ESC key
escapeKey: true,
});
const role = useRole(context, { role: 'tooltip' });
// Merge all interactions into reference and floating props
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
focus,
dismiss,
role,
]);
// Clone the trigger child to inject floating-ui props and aria-describedby
const trigger = React.cloneElement(
children,
getReferenceProps({
ref: refs.setReference,
...children.props,
})
);
return (
<>
{trigger}
{isOpen && (
<div
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
className="z-50 px-3 py-1.5 text-xs font-medium text-white bg-slate-900 rounded-md shadow-sm"
>
{label}
<FloatingArrow
ref={arrowRef}
context={context}
className="fill-slate-900"
/>
</div>
)}
</>
);
};
Deep Dive into the Implementation Details
1. Achieving Hover and Focus Parity
By combining useHover(context) and useFocus(context), we ensure that keyboard navigation triggers the exact same state machine as mouse movement.
const hover = useHover(context, { handleClose: safePolygon() });
const focus = useFocus(context);
Notice the safePolygon() helper inside useHover. When moving the mouse diagonally toward the tooltip, users often pass through the gap between the trigger and the tooltip. safePolygon creates an invisible trapezoidal boundary that keeps the tooltip open during transit.
2. Automatic ARIA Wiring via useInteractions
One of the greatest benefits of @floating-ui/react is how it automates ARIA attributes. When useRole(context, { role: 'tooltip' }) is evaluated alongside useFocus and useHover, getFloatingProps() and getReferenceProps() automatically handle:
- Generating and attaching unique
idvalues. - Applying
aria-describedbyto the reference element when the tooltip opens. - Managing
tabIndexand blur events cleanly.
This guarantees that screen readers like VoiceOver, NVDA, and JAWS instantly read out the tooltip content when the element receives focus or pointer hover.
3. Collision Detection and Positioning Middleware
Without proper overflow handling, tooltips frequently get clipped by parent containers with overflow: hidden or get cut off at the edge of the viewport. Our middleware configuration solves this elegantly:
middleware: [
offset(8), // Leaves an 8px gap between trigger and tooltip
flip(), // Flips placement if there's no room (e.g., top -> bottom)
shift({ padding: 8 }), // Shifts tooltip horizontally/vertically to stay inside viewport
arrow({ element: arrowRef }), // Positions a visual CSS arrow
],
Usage Example
Using our new component is declarative and straightforward. Because we clone the child element to inject refs and aria attributes, you can pass any standard interactive element (like a button or link) directly.
import React from 'react';
import { Tooltip } from './Tooltip';
export function App() {
return (
<div className="p-12 flex justify-center">
<Tooltip label="Deletes the selected project permanently" placement="top">
<button className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400">
Delete Project
</button>
</Tooltip>
</div>
);
}
Summary Checklist for Accessible Tooltips
Before pushing your tooltip component to production, verify the following:
- Tab to target: Pressing
Tabfocuses the trigger element, and the tooltip appears immediately. - Screen reader verification: Screen readers announce the button label followed by the tooltip text.
- Escape dismissal: Pressing
Escapehides the tooltip without moving focus away from the button. - Viewport bounds: Placing a tooltip on an element at the very edge of the browser window does not cause layout clipping.
- Non-interactive: Ensure users cannot tab inside the tooltip content. If your overlay requires interactive buttons or links, refactor it into a Popover component instead.