Hover and Hide: Crafting Accessible Tooltips and Popovers in React
Learn how to build a production-ready, highly accessible tooltip and popover system in React and TypeScript using Floating UI, complete with ARIA attributes, focus management, and keyboard dismissal.
Hover and Hide: Crafting Accessible Tooltips and Popovers in React
Tooltips and popovers are ubiquitous UI patterns. They offer contextual hints or hide rich, interactive content behind a simple trigger. However, despite their commonality, they are notoriously difficult to implement correctly.
Building a truly robust floating element system requires solving several hard problems:
- Positioning: Ensuring the element never clips off the edge of the viewport.
- Accessibility: Connecting screen readers to descriptions and managing focus correctly.
- Interaction Design: Handling hover intent, focus rings, and touch devices.
- Keyboard Navigation: Allowing users to dismiss popovers instantly using the
Escapekey.
In this guide, we will build a robust, accessible tooltip and popover system in React and TypeScript using Floating UI—the industry standard library for positioning floating elements—alongside native React hooks for keyboard and focus management.
Why Floating UI?
In the past, developers relied on CSS absolute positioning combined with complex calculation scripts to place tooltips. This approach inevitably fails when users scroll, resize the browser, or encounter cramped viewports.
Floating UI provides low-level positioning primitives (@floating-ui/react) that calculate optimal coordinates, handle flip behaviors when hitting boundaries, shift elements to stay on-screen, and render CSS arrows pointing precisely at the trigger element.
Let’s start by installing the required packages:
npm install @floating-ui/react
—v
The Anatomy of an Accessible Tooltip
Before writing code, let’s review the WAI-ARIA Authoring Practices Guide (APG) for tooltips and popovers. A proper tooltip requires:
aria-describedby: The trigger element must reference the ID of the tooltip container so screen readers announce the tooltip’s contents when the trigger receives focus or hover.- Role: The tooltip element should carry
role="tooltip". - Focus vs. Hover: Tooltips should appear on both mouse hover and keyboard focus, and disappear when the user moves the mouse away or presses
Escape.
Let’s build a unified Tooltip component that encapsulates these requirements.
Building the Tooltip Component
import React, { useState } from 'react';
import {
useFloating,
useId,
useHover,
useFocus,
useDismiss,
useRole,
useInteractions,
FloatingPortal,
offset,
shift,
flip,
arrow,
} from '@floating-ui/react';
interface TooltipProps {
label: string;
children: React.ReactNode;
}
export const Tooltip: React.FC<TooltipProps> = ({ label, children }) => {
const [isOpen, setIsOpen] = useState(false);
const arrowRef = React.useRef(null);
const {
refs,
floatingStyles,
context,
middlewareData,
} = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement: 'top',
middleware: [
offset(8),
flip(),
shift({ padding: 8 }),
arrow({ element: arrowRef }),
],
});
// Interaction hooks
const hover = useHover(context, { move: false });
const focus = useFocus(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: 'tooltip' });
// Merge interactions into reference and floating props
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
focus,
dismiss,
role,
]);
const headingId = useId();
return (
<>
{/* Clone or wrap trigger element with reference props */}
{React.cloneElement(children as React.ReactElement,
getReferenceProps({
ref: refs.setReference,
...((children as React.ReactElement).props || {}),
})
)}
{isOpen && (
<FloatingPortal>
<div
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
id={headingId}
className="z-50 px-3 py-1.5 text-xs font-medium text-white bg-slate-900 rounded-md shadow-sm"
>
{label}
<div
ref={arrowRef}
style={{
position: 'absolute',
left: middlewareData.arrow?.x != null ? `${middlewareData.arrow.x}px` : '',
top: middlewareData.arrow?.y != null ? `${middlewareData.arrow.y}px` : '',
}}
className="w-2 h-2 bg-slate-900 rotate-45"
/>
</div>
</FloatingPortal>
)}
</>
);
};
Key Takeaways from the Tooltip Implementation:
FloatingPortal: Renders the tooltip at the end of the DOM (usually insidedocument.body). This guarantees that your floating elements will never be clipped by parent containers withoverflow: hiddenor stacking context issues (z-index).useHoveranduseFocus: Floating UI automatically synchronizes hover and focus states, ensuring keyboard-only users get the exact same visual cues as mouse users.useDismiss: Automatically hooks into theEscapekey to close the floating element and returns focus gracefully.
Leveling Up: Building an Interactive Popover
While tooltips contain plain static text and should not receive keyboard focus inside them, popovers often contain interactive elements like buttons, links, or form fields.
When a popover opens, keyboard focus must move inside the popover container so users can navigate its contents cleanly.
The Popover Component
import React, { useState } from 'react';
import {
useFloating,
useClick,
useDismiss,
useRole,
useInteractions,
useMergeRefs,
FloatingPortal,
offset,
shift,
flip,
} from '@floating-ui/react';
interface PopoverProps {
trigger: React.ReactNode;
content: (close: () => void) => React.ReactNode;
}
export const Popover: React.FC<PopoverProps> = ({ trigger, content }) => {
const [isOpen, setIsOpen] = useState(false);
const {
refs,
floatingStyles,
context,
} = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement: 'bottom-start',
middleware: [offset(8), flip(), shift({ padding: 8 })],
});
const click = useClick(context);
const dismiss = useDismiss(context, {
// Esc key will dismiss the popover and return focus to the trigger
escapeKey: true,
});
const role = useRole(context, { role: 'dialog' });
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
dismiss,
role,
]);
const triggerRef = useMergeRefs([
refs.setReference,
(trigger as any).ref,
]);
return (
<>
{React.cloneElement(trigger as React.ReactElement, {
...getReferenceProps({
ref: triggerRef,
...((trigger as React.ReactElement).props || {}),
}),
})}
{isOpen && (
<FloatingPortal>
<div
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
className="z-50 w-72 p-4 bg-white border border-slate-200 rounded-lg shadow-xl"
>
{content(() => setIsOpen(false))}
</div>
</FloatingPortal>
)}
</>
);
};
Solving Edge Cases: Focus Management and Escape Key Dismissal
When dealing with floating elements, accessibility bugs usually stem from improper focus management. Let’s look at how Floating UI solves these challenges out-of-the-box.
1. The Escape Key Contract
Users expect the Escape key to act as the universal “get me out of here” command. The useDismiss hook listens globally for keydown events on the window while the floating element is active:
const dismiss = useDismiss(context, {
escapeKey: true,
outsidePress: true, // Dismisses when clicking outside the popover
});
When Escape is pressed:
- The floating element state (
isOpen) flips tofalse. - Focus automatically returns to the element stored in
refs.reference(the button that triggered the popover).
2. Preventing Scrolling and Clipping
By utilizing Floating UI’s shift() and flip() middleware, your popover will automatically adjust its placement if it encounters a browser viewport edge:
middleware: [
offset(10),
flip({ fallbackPlacements: ['top-start', 'bottom-end'] }),
shift({ padding: 16 }),
]
flip(): If a popover opens downwards but overflows the bottom of the screen, Floating UI flips it to open upwards.shift(): If a popover overflows on the left or right, it nudges the element inward, maintaining a guaranteed 16px padding from the viewport border.
Putting It All Together in an App
Here is how clean and declarative your component usage looks when consumed in a real-world React dashboard:
export function Dashboard() {
return (
<div className="p-8 flex gap-4 items-center">
{/* Simple Tooltip Example */}
<Tooltip label="Delete item permanently">
<button className="px-4 py-2 bg-red-50 text-red-600 rounded-md hover:bg-red-100">
Delete
</button>
</Tooltip>
{/* Rich Popover Example */}
<Popover
trigger={
<button className="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">
Options
</button>
}
content={(close) => (
<div className="space-y-3">
<h4 className="font-medium text-slate-900">Preferences</h4>
<p className="text-sm text-slate-600">
Manage your notification settings for this workspace.
</p>
<div className="flex justify-end gap-2 pt-2">
<button
onClick={close}
className="px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100 rounded"
>
Cancel
</button>
<button
onClick={() => {
/* Save logic */
close();
}}
className="px-3 py-1.5 text-xs bg-indigo-600 text-white rounded"
>
Save Changes
</button>
</div>
</div>
)}
/>
</div>
);
}
Conclusion
Creating accessible floating components doesn’t have to mean writing hundreds of lines of brittle math and event-listener code. By combining React, TypeScript, and Floating UI, we get robust viewport calculations, correct ARIA role management, seamless keyboard focus restoration, and bulletproof Escape key dismissal out of the box.
Always remember your checklist when building floating UI elements:
- ✅ Use
FloatingPortalto dodge stacking context and clipping traps. - ✅ Hook into
useDismissto support standard keyboard behaviors (Escape). - ✅ Ensure interactive popovers trap or transition focus appropriately, while static tooltips rely on
aria-describedby.
Happy coding, and build accessible apps!