Building an Accessible Tooltip in React: Floating UI and Keyboard Hover States
Learn how to build a fully accessible React tooltip component featuring Floating UI positioning, keyboard focus visibility, touch interactions, and proper ARIA wiring.
Building an Accessible Tooltip in React: Floating UI and Keyboard Hover States
Tooltips are among the most deceptively simple components in web development. At first glance, a tooltip is nothing more than a small popover that appears when a user hovers over an element. However, building a truly robust and accessible tooltip requires navigating a maze of edge cases: precise viewport positioning, mobile touch behaviors, screen reader announcements, and keyboard focus states.
In this installment of our accessibility engineering series, we are going to build a production-ready, accessible tooltip component in React using TypeScript and Floating UI.
The Anatomy of an Accessible Tooltip
Before diving into code, let’s establish what makes a tooltip accessible according to the WAI-ARIA Authoring Practices Guide (APG):
- Semantic Association: The trigger element must be explicitly linked to the tooltip content using
aria-describedby. - Keyboard Accessibility: Tooltips must appear not only on mouse hover, but also when the trigger element receives keyboard focus (
focus). They must also be dismissible via theEscapekey. - Screen Reader Support: When the trigger receives focus or hover, screen readers should immediately read the descriptive text without interrupting the flow of the document.
- Viewport Awareness: Tooltips must never clip off the edge of the screen, regardless of where the user is looking.
Let’s design a component architecture that satisfies all these requirements natively.
Setting Up the Dependencies
We will use @floating-ui/react for our positioning engine. It provides high-performance, collision-aware coordinate calculations and handles pointer interactions effortlessly.
npm install @floating-ui/react
Crafting the Tooltip Component
Below is the complete implementation of our accessible Tooltip component written in TypeScript. It manages its own open state, handles mouse/focus events, binds ARIA attributes automatically, and repositions itself intelligently using Floating UI.
import React, { useState, useId, useRef, cloneElement, ReactElement } from "";
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
useHover,
useFocus,
useDismiss,
useRole,
useInteractions,
FloatingPortal,
safePolygon,
} from "@floating-ui/react";
interface TooltipProps {
content: React.ReactNode;
children: ReactElement;
placement?: "top" | "bottom" | "left" | "right";
delay?: number;
}
export const Tooltip: React.FC<TooltipProps> = ({
content,
children,
placement = "top",
delay = 150,
}) => {
const [isOpen, setIsOpen] = useState(false);
const tooltipId = useId();
const { x, y, strategy, refs, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement,
whileElementsMounted: autoUpdate,
middleware: [
offset(8),
flip({
fallbackAxisSideDirection: "start",
}),
shift({ padding: 8 }),
],
});
// Interaction hooks from Floating UI
const hover = useHover(context, {
delay: { open: delay, close: 75 },
handleClose: safePolygon(), // Allows user to mouse over the tooltip itself
});
const focus = useFocus(context);
const dismiss = useDismiss(context, {},
);
const role = useRole(context, { role: "tooltip" });
// Merge interactions into getReferenceProps and getFloatingProps
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
focus,
dismiss,
role,
]);
// Clone the child element to inject accessibility refs and event handlers
const trigger = cloneElement(
children,
getReferenceProps({
ref: refs.setReference,
"aria-describedby": isOpen ? tooltipId : undefined,
// Ensure the child can receive focus if it isn't natively focusable
...(!children.props.tabIndex && { tabIndex: 0 }),
})
);
return (
<>
{trigger}
{isOpen && (
<FloatingPortal>
<div
{...getFloatingProps({
ref: refs.setFloating,
id: tooltipId,
style: {
position: strategy,
top: y ?? 0,
left: x ?? 0,
zIndex: 9999,
},
})}
className="px-3 py-1.5 text-xs font-medium text-white bg-gray-900 rounded-md shadow-lg pointer-events-none max-w-xs break-words"
>
{content}
</div>
</FloatingPortal>
)}
</>
);
};
Breaking Down the Implementation Details
1. Robust ARIA Wiring via aria-describedby
Screen readers rely on aria-describedby to associate descriptive nodes with focusable elements. By dynamically assigning tooltipId to the trigger only when isOpen is true, we prevent unnecessary noise while ensuring complete context is provided upon activation.
"aria-describedby": isOpen ? tooltipId : undefined
2. Bulletproof Positioning with Floating UI
Positioning popovers using absolute CSS hacks often fails when elements live inside scrollable containers or near viewport boundaries. Floating UI solves this with a trio of middleware:
offset(8): Leaves an 8-pixel gap between the trigger and the tooltip.flip(): Automatically flips the tooltip to the opposite side (e.g., from top to bottom) if there isn’t enough vertical room.shift(): Pushes the tooltip horizontally if it threatens to overflow the left or right edges of the screen.
3. Handling Mouse Hover, Keyboard Focus, and Touch
A common failure mode in custom tooltips is ignoring keyboard users. By combining useHover and useFocus, Floating UI ensures that pressing Tab to navigate onto an element triggers the exact same tooltip experience as hovering with a mouse.
Furthermore, the safePolygon() helper solves a notoriously frustrating UX problem: losing hover state when trying to move the mouse pointer from the trigger element onto the tooltip body itself.
const hover = useHover(context, {
delay: { open: delay, close: 75 },
handleClose: safePolygon(),
});
4. Accessibility Portals
Using <FloatingPortal> renders our tooltip DOM node directly into the document body (usually under document.body). This guarantees that your tooltips will never be clipped by parent containers that have overflow: hidden or restrictive stacking contexts (z-index).
Consuming the Component
Using our tooltip is straightforward. Pass any React element as a child, and supply your descriptive content string or JSX elements:
import React from "";
import { Tooltip } from "./Tooltip";
export function UserProfileCard() {
return (
<div className="p-6">
<Tooltip content="Verified account holder since 2021" placement="top">
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg">
Edit Profile
</button>
</Tooltip>
</div>
);
}
Testing Accessibility Checklist
Before shipping your tooltip component to production, run through this quick manual validation checklist:
- Keyboard Navigation: Press
Tabuntil the trigger is focused. Does the tooltip appear immediately? - Escape Dismissal: While the tooltip is open via keyboard or hover, press the
Escapekey. Does it disappear cleanly without moving focus? - Screen Reader Verification: Turn on VoiceOver (macOS) or NVDA (Windows). Focus the trigger element. Does the screen reader announce the button label followed by the tooltip content?
- Collision Testing: Place your tooltip trigger at the extreme right or bottom edge of your browser window. Does Floating UI successfully flip or shift the content into view?
By leveraging Floating UI alongside rigorous WAI-ARIA attributes, you can create delightful, universally accessible micro-interactions that serve all users equally.