Hover and Hide: Building an Accessible Tooltip and Popover Component in React
A practical, code-heavy walkthrough showing how to combine Floating UI with proper ARIA attributes, hover/focus state management, and keyboard accessibility for React tooltips and popovers.
Hover and Hide: Building an Accessible Tooltip and Popover Component in React
Tooltips and popovers seem simple at first glance: a small box of content that appears when a user interacts with an element. Yet, building them correctly in a modern React application is deceptively complex.
A truly production-ready floating element must satisfy several non-trivial requirements:
- Smart Positioning: It must automatically flip or shift when it approaches the edge of the viewport.
- Accessibility (a11y): Screen readers need proper ARIA attributes (
aria-describedby,aria-expanded, roles) to understand the relationship between the trigger and the content. - Keyboard Support: Users must be able to trigger the element via focus, and dismiss it instantly using the
Escapekey. - State Synchronization: Hover states, focus states, and click states must not conflict.
In this post, we will build a robust, accessible tooltip and popover component system in React and TypeScript using Floating UI, the industry-standard positioning engine.
Why Floating UI?
While you could position elements using absolute CSS and manual window resize listeners, doing so edge-case-proofs your UI poorly. Floating UI provides low-level positioning primitives that calculate coordinates dynamically, handling boundary collisions, scrolling containers, and arrow positioning with zero layout thrashing.
Let’s start by installing our dependencies:
npm install @floating-ui/react
Building the Accessible Tooltip Component
A tooltip is intended for brief, informative hints. It appears on hover or focus, does not contain interactive elements (like buttons or links), and vanishes when the user moves away or presses Escape.
1. Setting Up the Tooltip Hook and State
We’ll use @floating-ui/react’s built-in hooks (useFloating, useInteractions, useHover, useFocus, useDismiss, useRole) to handle state management cleanly.
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.ReactElement;
placement?: 'top' | 'bottom' | 'left' | 'right';
}
export function Tooltip({ content, children, placement = 'top' }: TooltipProps) {
const [isOpen, setIsOpen] = useState(false);
const arrowRef = React.useRef(null);
const { refs, floatingStyles, 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, 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: Add smooth mount/unmount animations
const { isMounted, styles } = useTransitionStyles(context, {
initial: { opacity: 0, transform: 'scale(0.95)' },
open: { opacity: 1, transform: 'scale(1)' },
close: { opacity: 0, transform: 'scale(0.95)' },
duration: 150,
});
// ... render logic below
}
2. Wiring Up ARIA Attributes and Rendering
Next, we merge the reference props onto our child element using cloneElement, ensuring we preserve existing refs and event handlers.
return (
<>
{isValidElement(children) &&
cloneElement(
children,
getReferenceProps({
ref: refs.setReference,
...children.props,
})
)}
{isMounted && (
<div
ref={refs.setFloating}
style={{ ...floatingStyles, ...styles }}
{...getFloatingProps()}
className="z-50 px-3 py-1.5 text-xs font-medium text-white bg-slate-900 rounded shadow-lg pointer-events-none"
>
{content}
<FloatingArrow
ref={arrowRef}
context={context}
className="fill-slate-900"
/>
</div>
)}
</>
);
}
Accessibility Note: Notice the
role: 'tooltip'configuration passed touseRole. Floating UI automatically generates the necessaryaria-describedbyIDs connecting the trigger element to the tooltip content node, making it fully screen-reader compliant.
Building the Popover Component
Unlike tooltips, popovers are rich containers that can hold interactive elements like form inputs, checkboxes, and action buttons. Popovers are typically triggered by clicks, managed via aria-expanded, and must trap focus or dismiss predictably.
1. Popover Architecture
Let’s implement a compound-component style popover system or a flexible single-component popover with internal state management.
import React, { useState, useRef } from 'react';
import {
useFloating,
useClick,
useDismiss,
useRole,
useInteractions,
offset,
flip,
shift,
arrow,
FloatingArrow,
FloatingFocusManager,
useTransitionStyles,
} from '@floating-ui/react';
interface PopoverProps {
renderContent: (close: () => void) => React.ReactNode;
children: React.ReactElement;
placement?: 'bottom-start' | 'bottom-end' | 'top' | 'bottom';
}
export function Popover({ renderContent, children, placement = 'bottom-start' }: PopoverProps) {
const [isOpen, setIsOpen] = useState(false);
const arrowRef = useRef(null);
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement,
middleware: [
offset(10),
flip(),
shift({ padding: 10 }),
arrow({ element: arrowRef }),
],
});
const click = useClick(context);
const dismiss = useDismiss(context, {
// Pressing Escape or clicking outside dismisses the popover
escapeKey: 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)' },
});
const closePopover = () => setIsOpen(false);
return (
<>
{React.cloneElement(
children,
getReferenceProps({
ref: refs.setReference,
'aria-expanded': isOpen,
...children.props,
})
)}
{isMounted && (
<FloatingFocusManager context={context} modal={false}>
<div
ref={refs.setFloating}
style={{ ...floatingStyles, ...styles }}
{...getFloatingProps()}
className="z-50 w-72 p-4 bg-white border border-slate-200 rounded-xl shadow-xl text-slate-800"
>
{renderContent(closePopover)}
<FloatingArrow
ref={arrowRef}
context={context}
className="fill-white stroke-slate-200"
/>
</div>
</FloatingFocusManager>
)}
</>
);
}
2. Handling Focus Management Correctly
Notice the inclusion of <FloatingFocusManager modal={false}>. This is critical for popovers containing interactive content:
- When the popover opens, keyboard focus shifts inside the floating element so screen reader users and keyboard-only navigators immediately access the content.
- When the popover closes, focus returns naturally to the trigger element, preventing the user’s focus context from resetting to the top of the document.
Putting It Together: Usage Example
Here is how clean and declarative your component usage looks in a real application:
export function App() {
return (
<div className="p-12 flex gap-8 items-center">
{/* Tooltip Example */}
<Tooltip content="Copy code snippet to clipboard">
<button className="px-4 py-2 bg-slate-100 rounded-md font-medium text-sm hover:bg-slate-200 transition">
Hover me
</button>
</Tooltip>
{/* Popover Example */}
<Popover
placement="bottom-start"
renderContent={(close) => (
<div className="space-y-3">
<h4 className="font-semibold text-sm text-slate-900">Filter Options</h4>
<p className="text-xs text-slate-500">Select your preferred view settings below.</p>
<div className="flex justify-end gap-2 pt-2">
<button
onClick={close}
className="px-3 py-1.5 text-xs bg-slate-900 text-white rounded font-medium"
>
Apply
</button>
</div>
</div>
)}
>
<button className="px-4 py-2 bg-blue-600 text-white rounded-md font-medium text-sm hover:bg-blue-700 transition">
Open Popover
</button>
</Popover>
</div>
);
}
Summary Checklist for Accessible Floating Elements
When building or auditing custom floating UI components, ensure you verify:
- Semantic Roles: Use
role="tooltip"for non-interactive helper text, androle="dialog"orrole="menu"for interactive popovers. - Keyboard Dismissal: The
Escapekey must close the floating element and return focus appropriately. - Focus Management: Interactive popovers must shift focus inside upon opening and return focus to the trigger upon closing using tools like Floating UI’s
FloatingFocusManager. - ARIA States: Trigger buttons must correctly expose
aria-expanded(for popovers/menus) or rely on programmaticaria-describedbylinkage (for tooltips).
By leveraging Floating UI alongside React state primitives, you get robust cross-browser positioning out of the box while maintaining strict adherence to WAI-ARIA authoring practices.