Hovering in the Dark: Crafting an Accessible Tooltip & Popover System in React
A comprehensive step-by-step technical guide on building a production-ready, accessible tooltip and popover component system in React and TypeScript using Floating UI.
Hovering in the Dark: Crafting an Accessible Tooltip & Popover System in React
Tooltips and popovers seem deceptively simple. You hover over a button, a little box of text appears. You click a profile icon, a menu pops open. Easy, right?
Not quite. When you pull back the curtain, standard UI tooltips and popovers are a minefield of edge cases: viewport overflows, z-index wars, screen reader blindness, focus trapping failures, and lack of keyboard navigation.
In this deep dive, we will build a production-ready, highly accessible Tooltip and Popover component system in React and TypeScript. We will leverage Floating UI for robust anchor positioning and collision detection, while hand-crafting our accessibility (a11y) layer to ensure screen readers and keyboard-only users are treated as first-class citizens.
The Architecture: Tooltips vs. Popovers
Before writing code, let’s define our terms because they dictate our accessibility and interaction patterns:
- Tooltips: Brief, informative hints triggered by hover or focus. They describe an element. They are non-interactive (no links or buttons inside), disappear when the mouse leaves or focus shifts, and map to
role="tooltip"usingaria-describedby. - Popovers: Rich content containers triggered by click. They can contain interactive elements (forms, buttons, links), require explicit dismissal (like pressing
Escapeor clicking outside), trap or manage focus, and map toaria-expandedand dialog/menu semantics.
Because they share underlying mechanics—positioning, collision detection, and portal management—we can build a unified primitive using Floating UI and specialize them via React composition.
Step 1: Setting Up the Dependencies
We need @floating-ui/react, the premier library for handling floating elements. It provides hooks for positioning, interaction handling (hover, click, dismiss), and accessibility out of the box.
npm install @floating-ui/react
npm install -D typescript @types/react
Step 2: Crafting the Core Hook
Let’s create a custom hook, usePopoverSystem, that encapsulates Floating UI logic, state management, and accessibility bindings. This keeps our components clean and separates behavior from presentation.
import { useState } from 'use';
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
useClick,
useHover,
useDismiss,
useRole,
useInteractions,
FloatingPortal,
safePolygon,
} from '@floating-ui/react';
interface UsePopoverOptions {
initialOpen?: boolean;
onOpenChange?: (open: boolean) => void;
placement?: 'top' | 'bottom' | 'left' | 'right';
strategy?: 'tooltip' | 'popover';
}
export function usePopoverSystem({
initialOpen = false,
onOpenChange,
placement = 'top',
strategy = 'tooltip',
}: UsePopoverOptions) {
const [isOpen, setIsOpen] = useState(initialOpen);
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
onOpenChange?.(open);
};
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: handleOpenChange,
placement,
whileElementsMounted: autoUpdate,
middleware: [
offset(8),
flip({
fallbackAxisSideDirection: 'start',
crossAxis: false,
}),
shift({ padding: 8 }),
],
});
// Interaction hooks based on strategy
const hover = useHover(context, {
enabled: strategy === 'tooltip',
handleClose: safePolygon(),
});
const click = useClick(context, {
enabled: strategy === 'popover',
});
const dismiss = useDismiss(context);
const role = useRole(context, {
role: strategy === 'tooltip' ? 'tooltip' : 'dialog',
});
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
click,
dismiss,
role,
]);
return {
isOpen,
refs,
floatingStyles,
context,
getReferenceProps,
getFloatingProps,
};
}
Why safePolygon()?
If you have ever hovered over a tooltip and tried to move your mouse directly into the tooltip content only for it to vanish instantly, you’ve experienced a broken hover buffer. Floating UI’s safePolygon() generates an invisible safety triangle that allows the mouse to bridge the gap between the reference element and the floating content seamlessly.
Step 3: Building the Tooltip Component
Now, let’s assemble the Tooltip component using React Context to share state between the trigger and the floating panel without prop drilling.
import React, { createContext, useContext, useId, useMemo } from 'react';
import { usePopoverSystem } from './usePopoverSystem';
import { FloatingPortal } from '@floating-ui/react';
interface TooltipContextType {
getReferenceProps: (userProps?: React.HTMLProps<Element>) => Record<string, unknown>;
getFloatingProps: (userProps?: React.HTMLProps<Element>) => Record<string, unknown>;
refs: any;
floatingStyles: React.CSSProperties;
isOpen: boolean;
labelId: string;
}
const TooltipContext = createContext<TooltipContextType | null>(null);
export const Tooltip: React.FC<{ children: React.ReactNode; placement?: 'top' | 'bottom' | 'left' | 'right' }> = ({
children,
placement = 'top',
})
=> {
const labelId = useId();
const system = usePopoverSystem({ placement, strategy: 'tooltip' });
const value = useMemo(
() => ({ ...system, labelId }),
[system, labelId]
);
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
};
Creating Trigger and Content Subcomponents
export const TooltipTrigger: React.FC<{ children: React.ReactNode; asChild?: boolean }> = ({
children,
asChild = false,
}) => {
const context = useContext(TooltipContext);
if (!context) throw new Error('TooltipTrigger must be used within a Tooltip');
const { refs, getReferenceProps, labelId, isOpen } = context;
// Basic clone element approach for child triggers
if (asChild && React.isValidElement(children)) {
return React.cloneElement(
children,
getReferenceProps({
ref: refs.setReference,
'aria-describedby': isOpen ? labelId : undefined,
...(children.props as any),
})
);
}
return (
<button
ref={refs.setReference}
aria-describedby={isOpen ? labelId : undefined}
{...getReferenceProps()}
type="button"
>
{children}
</button>
);
};
export const TooltipContent: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const context = useContext(TooltipContext);
if (!context) throw new Error('TooltipContent must be used within a Tooltip');
const { refs, floatingStyles, getFloatingProps, isOpen, labelId } = context;
if (!isOpen) return null;
return (
<FloatingPortal>
<div
ref={refs.setFloating}
style={floatingStyles}
id={labelId}
role="tooltip"
{...getFloatingProps()}
className="z-50 px-3 py-1.5 text-xs text-white bg-slate-900 rounded shadow-lg animate-in fade-in-0 zoom-in-95"
>
{children}
</div>
</FloatingPortal>
);
};
Step 4: Building the Popover Component
Popovers differ from tooltips because they handle clicks, contain rich interactive DOM trees, and require robust focus management. Let’s build our Popover component using the same foundation.
import React, { createContext, useContext, useId, useMemo } from 'react';
import { usePopoverSystem } from './usePopoverSystem';
import { FloatingPortal, useDismiss, useFocusRing, useRole } from '@floating-ui/react';
const PopoverContext = createContext<any>(null);
export const Popover: React.FC<{ children: React.ReactNode; placement?: 'bottom' | 'top' | 'left' | 'right' }> = ({
children,
placement = 'bottom',
}) => {
const labelId = useId();
const descriptionId = useId();
const system = usePopoverSystem({ placement, strategy: 'popover' });
const value = useMemo(
() => ({ ...system, labelId, descriptionId }),
[system, labelId, descriptionId]
);
return <PopoverContext.Provider value={value}>{children}</PopoverContext.Provider>;
};
export const PopoverTrigger: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { refs, getReferenceProps, isOpen } = useContext(PopoverContext);
return (
<button
ref={refs.setReference}
aria-expanded={isOpen}
type="button"
{...getReferenceProps()}
>
{children}
</button>
);
};
export const PopoverContent: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { refs, floatingStyles, getFloatingProps, isOpen, labelId } = useContext(PopoverContext);
if (!isOpen) return null;
return (
<FloatingPortal>
<div
ref={refs.setFloating}
style={floatingStyles}
aria-labelledby={labelId}
tabIndex={-1}
{...getFloatingProps()}
className="z-50 w-72 p-4 bg-white border border-slate-200 rounded-lg shadow-xl text-slate-900 focus:outline-none"
>
{children}
</div>
</FloatingPortal>
);
};
Step 5: Tackling Accessibility & Edge Cases
Building components that look right is only half the battle. Let’s audit our implementation against core accessibility requirements:
1. Screen Reader Association (aria-describedby vs aria-expanded)
- Tooltips must expose
aria-describedbypointing to the tooltip’s unique ID only when open. This prevents screen readers from reading extraneous helper text continuously while the user navigates past the button. - Popovers must expose
aria-expanded="true|false"to announce state changes to assistive technology.
2. Viewport Collisions & Overflow
Using manual absolute positioning often causes tooltips to clip off screen edges. By integrating Floating UI’s flip() and shift() middleware:
flip()automatically re-orients the floating element to the opposite side (e.g., flipping from top to bottom) if there is insufficient vertical viewport space.shift()nudges the element horizontally or vertically so it stays completely inside the visible viewport bounds with padding.
3. Focus Trapping and Keyboard Dismissal
- Pressing Escape closes both tooltips and popovers automatically via
useDismiss. - When a Popover opens, focus should gracefully handle returning to the trigger element when closed. Floating UI manages this cleanly by storing the active element reference prior to opening.
Putting It Together: Usage Example
Here is how clean, readable, and expressive our component APIs become in consumer code:
export function App() {
return (
<div className="p-12 flex gap-8 items-center">
{/* Tooltip Example */}
<Tooltip placement="top">
<TooltipTrigger>
<span className="underline decoration-dotted cursor-help">Hover me</span>
</TooltipTrigger>
<TooltipContent>
This is a helpful, accessible tooltip description.
</TooltipContent>
</Tooltip>
{/* Popover Example */}
<Popover placement="bottom">
<PopoverTrigger>
<span className="px-4 py-2 bg-blue-600 text-white rounded font-medium">
Open Popover
</span>
</PopoverTrigger>
<PopoverContent>
<h3 className="font-bold text-sm mb-2">Account Settings</h3>
<p className="text-xs text-slate-500 mb-4">
Manage your preferences and notification parameters right here.
</p>
<button className="w-full py-1.5 bg-slate-100 hover:bg-slate-200 rounded text-xs font-semibold">
Save Preferences
</button>
</PopoverContent>
</Popover>
</div>
);
}
Conclusion
By pairing React Context, TypeScript, and Floating UI, we’ve built a bulletproof, accessible tooltip and popover engine. We avoided the common pitfalls of custom positioning math, secured seamless viewport edge-case handling, and guaranteed compliance with WCAG standards using proper ARIA attributes, semantic roles, and focus restoration.
Next time you’re tempted to write a quick div absolute top-0 tooltip hack, remember your users navigating via screen readers or small mobile viewports—take the extra step and build a system designed to scale.