Hover and Hide: Crafting an Accessible Tooltip and Popover Component in React
A deep-dive tutorial demonstrating how to pair floating-ui with React and TypeScript to build fully accessible tooltips and popovers complete with ARIA attributes and keyboard dismissal.
Hover and Hide: Crafting an Accessible Tooltip and Popover Component in React
Tooltips and popovers are ubiquitous UI patterns. Whether you are displaying a quick hint on hover or rendering a complex floating menu with form inputs, these floating elements seem deceptively simple to build. However, when you look beneath the hood, making them truly robust—handling precise viewport positioning, screen reader announcements, pointer interactions, and keyboard accessibility—turns out to be a surprisingly complex engineering challenge.
In this deep-dive tutorial, we will build a production-ready, fully accessible Tooltip and Popover component system in React using TypeScript and Floating UI. We will cover how to hook up ARIA attributes, manage focus, implement smooth hover/focus transitions, and gracefully handle escape-key dismissals without breaking the user flow.
The Anatomy of Floating Elements: Tooltip vs. Popover
Before writing code, it is crucial to distinguish between a Tooltip and a Popover:
- Tooltip: A brief, non-interactive label that appears on hover or focus to describe an element (e.g., “Copy to clipboard”). It is read automatically by screen readers via
aria-describedbyand does not trap focus. - Popover: An interactive floating container that may contain buttons, links, or inputs. It usually opens via a click event, traps or manages focus internally, and remains open until explicitly dismissed.
Because they share underlying positioning logic, we can leverage Floating UI as our positioning engine while tailoring the interaction hooks for each specific behavior.
Setting Up the Dependencies
First, let’s install @floating-ui/react, which provides headless hooks specifically optimized for React applications, handling positioning, interactions, and accessibility primitives out of the box.
npm install @floating-ui/react
Building the Tooltip Component
A great tooltip must satisfy several requirements:
- It appears on hover or keyboard focus.
- It disappears on mouse leave, blur, or when pressing the
Escapekey. - It is programmatically linked to the trigger via
aria-describedbyso screen readers announce it immediately.
Step 1: The Tooltip Hook and State
Let’s create Tooltip.tsx. We will use Floating UI’s hooks (useFloating, useInteractions, useHover, useFocus, useDismiss, and useRole) to wire everything together.
import React, { useState, cloneElement, isValidElement } from 'react';
import {
useFloating,
useInteractions,
useHover,
useFocus,
useDismiss,
useRole,
offset,
shift,
flip,
arrow,
FloatingArrow,
useTransitionStyles,
safePolygon,
} from '@floating-ui/react';
interface TooltipProps {
content: React.ReactNode;
children: React.ReactNode;
placement?: 'top' | 'bottom' | 'left' | 'right';
}
export const Tooltip: React.FC<TooltipProps> = ({
content,
children,
placement = 'top',
}) => {
const [isOpen, setIsOpen] = useState(false);
const arrowRef = React.useRef(null);
const {
refs,
floatings,
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,
restMs: 150,
handleClose: safePolygon(),
});
const focus = useFocus(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: 'tooltip' });
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
focus,
dismiss,
role,
]);
// Optional transition styles for smooth fading
const { isMounted, styles } = useTransitionStyles(context, {
initial: { opacity: 0, transform: 'scale(0.96)' },
open: { opacity: 1, transform: 'scale(1)' },
close: { opacity: 0, transform: 'scale(0.96)' },
duration: 150,
});
// Ensure children is a valid React element so we can attach reference props
if (!isValidElement(children)) {
return <>{children}</>;
}
const trigger = cloneElement(
children,
getReferenceProps({
ref: refs.setReference,
...children.props,
})
);
return (
<>
{trigger}
{isMounted && (
<div
ref={refs.setFloating}
style={{
...floatings.style,
...styles,
position: floatings.strategy,
top: floatings.y ?? 0,
left: floatings.x ?? 0,
zIndex: 50,
}}
{...getFloatingProps()}
className="px-3 py-1.5 text-xs font-medium text-white bg-slate-900 rounded shadow-lg pointer-events-none"
>
<FloatingArrow
ref={arrowRef}
context={context}
className="fill-slate-900"
/>
{content}
</div>
)}
</>
);
};
Why This Works for Accessibility
useRole(context, { role: 'tooltip' }): Automatically injects the correct ARIA role (role="tooltip") onto the floating element.useDismiss: Listens for theEscapekey, safely dismissing the tooltip and returning focus management to the trigger element without breaking standard keyboard navigation flows.useHoverwithsafePolygon: Prevents the tooltip from flickering or closing accidentally when moving the cursor from the trigger element to the floating box itself.
Building the Popover Component
Unlike tooltips, popovers contain interactive elements (like close buttons, form fields, or action links). When a popover opens, users expect to be able to move their focus directly into the popover content without losing their place.
Step 1: Crafting the Popover Primitive
Let’s create Popover.tsx. We will use useClick, useDismiss, and useRole with a dialog role.
import React, { useState, cloneElement, isValidElement } from 'react';
import {
useFloating,
useInteractions,
useClick,
useDismiss,
useRole,
useId,
offset,
shift,
flip,
FloatingFocusManager,
useTransitionStyles,
} from '@floating-ui/react';
interface PopoverProps {
render: (data: { close: () => void; labelId: string; descriptionId: string }) => React.ReactNode;
children: React.ReactNode;
placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
}
export const Popover: React.FC<PopoverProps> = ({
render,
children,
placement = 'bottom-start',
}) => {
const [isOpen, setIsOpen] = useState(false);
const {
refs,
floatings,
context,
} = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement,
middleware: [
offset(8),
flip(),
shift({ padding: 12 }),
],
});
const click = useClick(context);
const dismiss = useDismiss(context, {
// Ensure clicking outside or pressing Escape closes the popover
outsidePress: true,
});
const role = useRole(context, { role: 'dialog' });
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
dismiss,
role,
]);
const { isMounted, styles } = useTransitionStyles(context, {
initial: { opacity: 0, transform: 'translateY(8px)' },
open: { opacity: 1, transform: 'translateY(0)' },
close: { opacity: 0, transform: 'translateY(8px)' },
duration: 200,
});
const labelId = useId();
const descriptionId = useId();
const close = () => setIsOpen(false);
if (!isValidElement(children)) {
return <>{children}</>;
}
const trigger = cloneElement(
children,
getReferenceProps({
ref: refs.setReference,
...children.props,
})
);
return (
<>
{trigger}
{isMounted && (
<FloatingFocusManager context={context} modal={false}>
<div
ref={refs.setFloating}
style={{
...floatings.style,
...styles,
position: floatings.strategy,
top: floatings.y ?? 0,
left: floatings.x ?? 0,
zIndex: 50,
}}
{...getFloatingProps()}
aria-labelledby={labelId}
aria-describedby={descriptionId}
className="w-72 p-4 bg-white border border-slate-200 rounded-xl shadow-xl text-slate-800 focus:outline-none"
>
{render({ close, labelId, descriptionId })}
</div>
</FloatingFocusManager>
)}
</>
);
};
Focus Management with FloatingFocusManager
The secret weapon of accessible popovers in Floating UI is FloatingFocusManager.
- When
modal={false}is passed, it shifts focus directly into the first tabbable element inside the popover upon opening, ensuring keyboard and screen reader users aren’t left stranded on the trigger. - When the popover closes, focus is automatically and safely returned to the reference trigger element, preserving the user’s natural tab navigation sequence.
Putting It Together: Usage Example
Here is how clean and declarative your component consumption looks in practice across your application:
import React from 'react';
import { Tooltip } from './Tooltip';
import { Popover } from './Popover';
export function DashboardHeader() {
return (
<div className="flex items-center gap-4 p-6">
{/* Tooltip Example */}
<Tooltip content="Create a new repository">
<button className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-400">
New Item
</button>
</Tooltip>
{/* Popover Example */}
<Popover
render={({ close, labelId, descriptionId }) => (
<div className="space-y-3">
<h3 id={labelId} className="font-semibold text-slate-900">
Filter Notifications
</h3>
<p id={descriptionId} className="text-xs text-slate-500">
Select which channels you want to receive alerts from.
</p>
<div className="space-y-2 py-1">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" defaultChecked className="rounded text-indigo-600 focus:ring-indigo-500" />
Email Alerts
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" className="rounded text-indigo-600 focus:ring-indigo-500" />
SMS Notifications
</label>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-slate-100">
<button
onClick={close}
className="px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-100 rounded"
>
Cancel
</button>
<button
onClick={() => {
/* handle save */
close();
}}
className="px-3 py-1.5 text-xs font-medium bg-indigo-600 text-white rounded hover:bg-indigo-700"
>
Save Preferences
</button>
</div>
</div>
)}
>
<button className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 focus:outline-none focus:ring-2 focus:ring-slate-400">
Preferences
</button>
</Popover>
</div>
);
}
Accessibility Checklist & Best Practices
By leveraging Floating UI with our wrapper components, we automatically tick off major WCAG compliance requirements:
WCAG 2.1 Success Criterion 1.4.13 (Content on Hover or Focus): If hover or focus triggers additional content to become visible and then hidden, the content must be dismissable (via
Escape), hoverable (the user can move the mouse over the floating content without it disappearing), and persistent until the user removes focus or hover.
- Keyboard Navigable: Users can tab to the trigger, press
EnterorSpaceto open popovers, and useEscapeto close them without losing track of focus. - Screen Reader Announcements: ARIA roles (
role="tooltip",role="dialog") paired with unique IDs ensure assistive technologies communicate the purpose and content of floating layers immediately. - No Focus Trapping for Tooltips: Tooltips remain non-interactive (
pointer-events-none) so they never block underlying content or trap keyboard tab sequences unnecessarily.
Conclusion
Building accessible UI components does not have to mean writing hundreds of lines of brittle DOM measuring and event-listener code. By pairing React and TypeScript with Floating UI, you get robust math, bulletproof positioning, and world-class accessibility primitives out of the box.
Implement these patterns in your design system today to give both sighted and screen-reader users a seamless, frustration-free experience!