Hover, Focus, and Announce: Building an Accessible Tooltip in React
Learn how to build a production-ready, highly accessible tooltip component in React using TypeScript, @floating-ui, and robust ARIA attributes.
Hover, Focus, and Announce: Building an Accessible Tooltip in React
Tooltips are deceptively simple UI elements. At first glance, they appear to be nothing more than a small absolute-positioned box that appears when a user hovers over an element. However, building a tooltip that satisfies modern web accessibility standards (WCAG), handles complex layouts without clipping, supports keyboard navigation, and announces changes correctly to screen readers is a surprisingly nuanced engineering challenge.
In this guide, we will build a production-ready tooltip component in React using TypeScript and @floating-ui/react. By the end, you will have a resilient component that handles hover, focus states, Escape-key dismissal, viewport boundary flipping, and correct aria-describedby wiring.
The Anatomy of an Accessible Tooltip
Before writing code, let’s establish the requirements for a truly accessible tooltip:
- Semantic Association: Screen readers must connect the trigger element to the tooltip content via
aria-describedby. - State Parity: The tooltip must appear both when the trigger is hovered with a mouse and when it receives keyboard focus.
- Dismissal: Pressing the
Escapekey must immediately close the tooltip. - Smart Positioning: It should never clip off-screen, automatically flipping its placement if it hits a viewport boundary.
- No Focus Traps: Unlike a dialog or modal, a tooltip shouldn’t trap keyboard focus; the user should be able to tab right past it.
Setting Up the Dependencies
We’ll use @floating-ui/react for positioning calculations and interaction hooks, along with lucide-react for any icon triggers if needed.
npm install @floating-ui/react
npm install -D typescript @types/react
Building the Tooltip Component
Let’s implement our tooltip using a compound component or a flexible props-based approach. We will encapsulate the floating-ui logic inside a custom hook or directly within our component wrapper.
Step 1: Types and State Management
Create a file named Tooltip.tsx:
import React, { useState, cloneElement, ReactElement } from *'react'*;
import {
useFloating,
useHover,
useFocus,
useDismiss,
useRole,
useInteractions,
safePolygon,
offset,
flip,
shift,
arrow,
FloatingArrow,
useId,
Placement,
} from *@floating-ui/react*;
interface TooltipProps {
label: string;
children: ReactElement;
placement?: Placement;
}
export const Tooltip: React.FC<TooltipProps> = ({
label,
children,
placement = *'top'*,
}) => {
const [isOpen, setIsOpen] = useState(false);
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement,
middleware: [
offset(8),
flip(),
shift({ padding: 5 }),
],
});
// Interaction hooks
const hover = useHover(context, {
move: false,
// safePolygon allows users to move the mouse diagonally into the tooltip
handleClose: safePolygon(),
});
const focus = useFocus(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: *'tooltip'* });
// Merge all interactions into getting props
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
focus,
dismiss,
role,
]);
// Generate a stable unique ID for aria-describedby
const headingId = useId();
return (
<>
{cloneElement(
children,
getReferenceProps({
ref: refs.setReference,
...children.props,
})
)}
{isOpen && (
<div
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
id={headingId}
className="absolute z-50 px-3 py-1.5 text-xs text-white bg-slate-900 rounded-md shadow-lg pointer-events-none max-w-xs"
>
{label}
</div>
)}
</>
);
};
Step 2: Breaking Down the Accessibility Hooks
Let’s analyze why this implementation succeeds where standard custom onMouseEnter/onFocus handlers often fail:
useHover(context, { handleClose: safePolygon() }): WithoutsafePolygon, if a user tries to move their mouse from the button into the tooltip popup, the mouse briefly enters whitespace, triggering amouseleaveevent that instantly closes the tooltip.safePolygoncreates an invisible trapezoid corridor allowing smooth cursor transition.useFocus(context): Ensures that keyboard users tabbing through a form or navigation menu trigger the exact same visual state as mouse users.useDismiss(context): Automatically listens for theEscapekey globally when the tooltip is active, dismissing it instantly and returning full interaction control back to the page.useRole(context, { role: 'tooltip' }): Automatically applies the correct ARIA role mapping so assistive technology understands the element’s semantic context.
Wiring Up ARIA Attributes Properly
To pass WCAG criteria for tooltips, screen readers must explicitly know that the focused or hovered element is described by the tooltip popup content.
Floating-UI simplifies this by automatically generating and assigning aria-describedby relationships. When the trigger element receives focus or hover state, the underlying reference props inject the corresponding ID matching our tooltip’s DOM node ID (headingId).
Let’s review the rendered DOM state when open:
<!-- Trigger Element -->
<button
aria-describedby=":r1:"
type="button"
>
Delete Item
</button>
<!-- Tooltip Element -->
<div
id=":r1:"
role="tooltip"
style="position: absolute; top: 120px; left: 340px;"
>
Permanently remove this file from storage
</div>
When a screen reader user tabs to the button, it reads: “Delete Item, button. Permanently remove this file from storage.”
Enhancing with Floating Arrows and Polishing
Often, designers prefer a visual arrow pointing from the tooltip bubble back to the trigger element. We can easily integrate @floating-ui/react’s arrow middleware and <FloatingArrow /> component.
import React, { useState, cloneElement, ReactElement, useRef } from *'react'*;
import {
useFloating,
useHover,
useFocus,
useDismiss,
useRole,
useInteractions,
safePolygon,
offset,
flip,
shift,
arrow,
FloatingArrow,
useId,
Placement,
} from *@floating-ui/react*;
interface TooltipProps {
label: string;
children: ReactElement;
placement?: Placement;
}
export const Tooltip: React.FC<TooltipProps> = ({
label,
children,
placement = *'top'*,
}) => {
const [isOpen, setIsOpen] = useState(false);
const arrowRef = useRef(null);
const { refs, floatingStyles, context, middlewareData } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement,
middleware: [
offset(10),
flip(),
shift({ padding: 5 }),
arrow({ element: arrowRef }),
],
});
const hover = useHover(context, { move: false, handleClose: safePolygon() });
const focus = useFocus(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: *'tooltip'* });
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
focus,
dismiss,
role,
]);
const tooltipId = useId();
return (
<>
{cloneElement(
children,
getReferenceProps({
ref: refs.setReference,
...children.props,
})
)}
{isOpen && (
<div
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
id={tooltipId}
className="px-3 py-1.5 text-xs font-medium text-white bg-slate-900 rounded shadow-md pointer-events-none"
>
{label}
<FloatingArrow
ref={arrowRef}
context={context}
className="fill-slate-900"
/>
</div>
)}
</>
);
};
Usage Example
Using our newly minted component across your application is declarative and clean:
import React from *'react'*;
import { Tooltip } from *'./Tooltip'*;
import { Info, Trash2 } from *'lucide-react'*;
export function Dashboard() {
return (
<div className="p-8 flex gap-4 items-center">
<Tooltip label="View account statistics and telemetry">
<button className="p-2 rounded-lg bg-slate-100 hover:bg-slate-200 focus:outline-none focus:ring-2 focus:ring-blue-500">
<Info className="w-5 h-5 text-slate-700" />
</button>
</Tooltip>
<Tooltip label="This action cannot be undone" placement="right">
<button className="p-2 rounded-lg bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-500">
<Trash2 className="w-5 h-5 text-red-600" />
</button>
</Tooltip>
</div>
);
}
Common Pitfalls to Avoid
- Putting Interactive Content Inside Tooltips: Tooltips are strictly meant for short, non-interactive text descriptions. If your popup contains links, buttons, or form controls, you should implement an Accessible Popover or Menu component instead. Putting focusable elements inside an element mapped with
role="tooltip"creates a severe keyboard trap for screen reader users. - Failing to Handle Mobile Viewports: Touch screens do not have “hover” states. By ensuring
useFocusanduseDismissare configured, users tapping triggers can still invoke and dismiss tooltips reliably. - Hardcoding Positions: Never rely on CSS
position: absoluterelative to a static parent container for tooltips. Dynamic page resizing, scrolling, and flex containers will inevitably cause tooltips to clip. Always rely on coordinate engines like Floating UI.
Conclusion
Building accessible design system primitives requires looking past the surface visuals. By combining React’s cloning patterns, TypeScript safety, @floating-ui/react’s robust positioning logic, and proper ARIA wiring, you create a tooltip that works seamlessly for mouse users, keyboard navigators, and screen reader users alike.