Hover, Focus, Announce: Building an Accessible Tooltip Component in React
Learn how to build a robust, accessible tooltip and popover system in React using Floating UI and ARIA description patterns for seamless keyboard navigation and screen reader support.
Hover, Focus, Announce: Building an Accessible Tooltip Component in React
Tooltips and popovers are ubiquitous UI patterns. They provide contextual hints, rich metadata, and hidden controls without cluttering the primary layout. However, they are also among the most frequently mishandled components in web development.
Too often, tooltips rely solely on CSS :hover states and title attributes. This approach fails keyboard-only users, mobile users, and those relying on assistive technologies like screen readers. Building a truly accessible tooltip requires managing focus states, coordinating pointer intent, handling dynamic viewport positioning without layout shifts, and establishing a robust aria-describedby relationship.
In this guide, we will build a production-ready, fully accessible tooltip and popover system in React using Floating UI for positioning and native ARIA patterns for screen reader support.
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), a tooltip:
- Appears on hover and keyboard focus: It must not be restricted to mouse users. Users navigating via keyboard must trigger the tooltip when the trigger element receives focus.
- Is not interactive by default: A standard tooltip contains non-interactive text. If the overlay contains interactive elements (like links or buttons), it crosses into popover or dialog territory, requiring different focus management.
- Is programmatically linked: The trigger element must reference the tooltip container using
aria-describedby. - Dismisses gracefully: Pressing
Escapeshould hide the tooltip immediately without shifting focus away from the trigger.
Setting Up the Dependencies
We will use @floating-ui/react, the industry standard for positioning floating elements. It handles boundary detection, flipping, and shifting out of the box while offering first-class accessibility primitives.
npm install @floating-ui/react
Building the Tooltip Hook
To keep our components clean and modular, we will encapsulate Floating UI’s hooks and our accessibility logic inside a custom React hook. This hook will manage open state, positioning, hover/focus events, and ARIA attributes.
import {
useFloating,
useHover,
useFocus,
useDismiss,
useRole,
useInteractions,
offset,
shift,
flip,
arrow,
autoUpdate,
} from '@floating-ui/react';
import * as React from 'react';
interface UseTooltipOptions {
initialOpen?: boolean;
placement?: 'top' | 'bottom' | 'left' | 'right';
onOpenChange?: (open: boolean) => void;
}
export function useTooltip({
initialOpen = false,
placement = 'top',
onOpenChange,
}: UseTooltipOptions = {}) {
const [isOpen, setIsOpen] = React.useState(initialOpen);
const arrowRef = React.useRef(null);
const handleOpenChange = React.useCallback(
(open: boolean) => {
setIsOpen(open);
onOpenChange?.(open);
},
[onOpenChange]
);
const data = useFloating({
placement,
open: isOpen,
onOpenChange: handleOpenChange,
whileElementsMounted: autoUpdate,
middleware: [
offset(8),
flip(),
shift({ padding: 8 }),
arrow({ element: arrowRef }),
],
});
const context = data.context;
// Interaction hooks
const hover = useHover(context, {
move: false,
delay: { open: 200, close: 0 },
});
const focus = useFocus(context);
const dismiss = useDismiss(context, {
escapeKey: true,
// Optional: dismiss when clicking outside for popovers
referencePress: false,
});
// Role 'tooltip' tells screen readers this is an auxiliary description
const role = useRole(context, { role: 'tooltip' });
const interactions = useInteractions([hover, focus, dismiss, role]);
return React.useMemo(
() => ({
isOpen,
arrowRef,
...interactions,
...data,
}),
[isOpen, interactions, data]
);
}
Key Highlights of the Hook:
autoUpdate: Ensures that if the window resizes or the user scrolls while the tooltip is open, it repositions smoothly without lagging or detaching.useHoverwith delays: A 200ms open delay prevents flickering when users rapidly drag their cursors across the screen, while a 0ms close delay ensures snappy UI feedback.useRole: Automatically appliesrole="tooltip"to the floating element so assistive tech announces it correctly.
Implementing the Tooltip Component
Now, let’s assemble the React components. We will use React Context to share the floating state and interaction props between the trigger and the tooltip content.
import * as React from 'react';
import { useTooltip } from './useTooltip';
import { FloatingPortal, useId } from '@floating-ui/react';
interface TooltipContextType {
isOpen: boolean;
labelId: string;
descriptionId: string;
getReferenceProps: ReturnType<typeof useTooltip>['getReferenceProps'];
getFloatingProps: ReturnType<typeof useTooltip>['getFloatingProps'];
refs: ReturnType<typeof useTooltip>['refs'];
floatingStyles: ReturnType<typeof useTooltip>['floatingStyles'];
arrowRef: React.RefObject<HTMLDivElement | null>;
placement: ReturnType<typeof useTooltip>['placement'];
}
const TooltipContext = React.createContext<TooltipContextType | null>(null);
export function useTooltipContext() {
const context = React.useContext(TooltipContext);
if (!context) {
throw new Error('Tooltip components must be wrapped in <Tooltip />');
}
return context;
}
export interface TooltipProps {
children: React.ReactNode;
placement?: 'top' | 'bottom' | 'left' | 'right';
}
export function Tooltip({ children, placement = 'top' }: TooltipProps) {
const tooltip = useTooltip({ placement });
const descriptionId = useId();
const labelId = useId();
return (
<TooltipContext.Provider
value={{
isOpen: tooltip.isOpen,
labelId,
descriptionId,
getReferenceProps: tooltip.getReferenceProps,
getFloatingProps: tooltip.getFloatingProps,
refs: tooltip.refs,
floatingStyles: tooltip.floatingStyles,
arrowRef: tooltip.arrowRef,
placement: tooltip.placement,
}}
>
{children}
</TooltipContext.Provider>
);
}
Creating the Trigger and Content Sub-Components
The trigger element needs to spread the getReferenceProps and include the crucial aria-describedby attribute pointing to our tooltip content.
export function TooltipTrigger({ children }: { children: React.ReactNode }) {
const { refs, getReferenceProps, descriptionId, isOpen } = useTooltipContext();
// Clone element or wrap in a span. Wrapping in a span is safer for arbitrary children.
return (
<span
ref={refs.setReference}
{...getReferenceProps({
'aria-describedby': isOpen ? descriptionId : undefined,
})}
style={{ display: 'inline-block' }}
>
{children}
</span>
);
}
Next, the TooltipContent renders the actual floating overlay using FloatingPortal to escape stacking context issues (like overflow: hidden containers).
import { FloatingArrow } from '@floating-ui/react';
export function TooltipContent({ children }: { children: React.ReactNode }) {
const {
isOpen,
refs,
floatingStyles,
getFloatingProps,
descriptionId,
arrowRef,
placement,
} = useTooltipContext();
if (!isOpen) return null;
// Extract base placement for the arrow
const staticSide = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[placement.split('-')[0]] as any;
return (
<FloatingPortal>
<div
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps({
id: descriptionId,
className: 'tooltip-content',
})}
>
<FloatingArrow
ref={arrowRef}
context={refs.context!}
fill="#1e293b"
width={12}
height={6}
/>
{children}
</div>
</FloatingPortal>
);
}
Styling and Avoiding Layout Shifts
Smooth transitions enhance the user experience, but poorly implemented animations can cause flickering or layout recalculations. Because Floating UI calculates absolute coordinates, our tooltip container should use standard CSS transitions for opacity and transform.
.tooltip-content {
background-color: #1e293b;
color: #f8fafc;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.875rem;
line-height: 1.25rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
max-width: 240px;
z-index: 50;
pointer-events: none; /* Prevents tooltip from capturing mouse events */
opacity: 0;
transform: scale(0.95);
transition: opacity 150ms ease-out, transform 150ms ease-out;
}
/* When Floating UI mounts the element, we trigger the transition */
.tooltip-content[data-placement] {
opacity: 1;
transform: scale(1);
}
Handling Complex Non-Modal Overlays (Popovers)
What happens when your floating element needs to contain interactive elements, such as a “Learn More” link or a close button?
By definition, a tooltip cannot contain interactive content because screen readers expect tooltips to be read as brief descriptive strings attached to an element. If a user tabs into an interactive tooltip, they become trapped or disoriented.
For interactive overlays, we transition from a Tooltip pattern to a Popover pattern:
- Change the ARIA role from
role="tooltip"torole="dialog"oraria-haspopup="dialog". - Allow pointer events on the floating element (
pointer-events: auto). - Implement explicit focus trapping or allow natural tab flow into the popover content.
Here is how you can adapt the useTooltip hook into a usePopover hook:
// Inside a modified usePopover hook:
const role = useRole(context, { role: 'dialog' });
const click = useClick(context); // Toggle on click instead of just hover/focus
const dismiss = useDismiss(context);
const interactions = useInteractions([click, dismiss, role]);
And update the trigger ARIA attribute to aria-haspopup="dialog" and aria-expanded={isOpen} instead of aria-describedby.
Testing Accessibility
Building accessibility into your components is only half the battle; verifying it is the other. Follow this checklist to ensure your implementation is bulletproof:
- Keyboard Navigation: Tab to the trigger element. Does the tooltip appear immediately? Press
Tabagain or move focus away. Does it dismiss cleanly? PressEscapewhile focused on the trigger or inside the overlay. Does it close without shifting focus? - Screen Reader Verification: Test using VoiceOver (macOS) or NVDA (Windows). When focusing the trigger, does the screen reader announce the control followed by the tooltip text (e.g., “Delete account, button, Permanent action cannot be undone”)?
- Zoom & Viewport Boundaries: Zoom your browser to 200% and test boundary flipping. If you place a tooltip on an element at the far right edge of the screen, Floating UI should automatically shift or flip it to the left to prevent clipping.
Conclusion
Creating accessible UI overlays requires looking beyond simple CSS hover states. By combining Floating UI for robust, layout-safe positioning with native ARIA description patterns, you ensure that your tooltips and popovers are robust, performant, and accessible to everyone.
By treating accessibility as a core architectural requirement rather than an afterthought, you elevate the quality and resilience of your entire design system.