Hover, Focus, Escape: Crafting Accessible Tooltips and Popovers in React
Learn how to build production-ready, highly accessible tooltips and popovers from scratch using React, TypeScript, and Floating UI.
Hover, Focus, Escape: Crafting Accessible Tooltips and Popovers in React
Tooltips and popovers are ubiquitous interface elements. They provide supplementary context, actions, or details without cluttering the primary layout. Yet, despite their widespread use, they are among the most frequently broken components in terms of accessibility and usability.
A poorly implemented tooltip traps keyboard users, remains invisible to screen readers, or vanishes frustratingly when a user attempts to move their mouse to interact with it.
In this guide, we will build a robust, accessible tooltip and popover primitive from scratch using React, TypeScript, and Floating UI. We will handle mouse versus keyboard focus management, dynamic ARIA attributes, touch device quirks, screen reader announcements, and the elusive escape-key handler.
The Anatomy of Floating Content: Tooltip vs. Popover
Before diving into code, let’s establish the distinction between our two targets:
- Tooltips: Provide short, non-interactive descriptive text for an element. They appear on hover or keyboard focus and disappear immediately when the trigger loses focus or the user moves the mouse away. They cannot contain interactive elements (like links or buttons).
- Popovers: Contain rich, interactive content (forms, menus, action lists). They typically open on click, trap focus optionally, and remain open until explicitly dismissed via an escape key, clicking outside, or interacting with an internal element.
Both components, however, share a core requirement: precise positioning relative to a reference element while managing overflow and viewport boundaries. This is where Floating UI shines.
Setting Up the Primitives
First, let’s install @floating-ui/react, which provides headless hooks specifically engineered for accessibility and positioning.
npm install @floating-ui/react
We will build a unified compound component architecture that separates state management from presentation. Let’s create our types and context.
import React, { createContext, useContext, useState, useRef } from 'react';
import {
useFloating,
useInteractions,
useHover,
useFocus,
useClick,
useDismiss,
useRole,
offset,
flip,
shift,
arrow,
FloatingPortal,
safePolygon,
} from '@floating-ui/react';
interface PopoverContextType {
open: boolean;
setOpen: (open: boolean) => void;
refs: {
setReference: (node: HTMLElement | null) => void;
setFloating: (node: HTMLElement | null) => void;
};
floatingStyles: React.CSSProperties;
context: any;
labelId: string;
descriptionId: string;
}
const PopoverContext = createContext<PopoverContextType | null>(null);
export const usePopover = () => {
const context = useContext(PopoverContext);
if (!context) throw new Error('Popover components must be wrapped in <Popover />');
return context;
};
Crafting the Accessible Tooltip
Let’s construct a tooltip primitive. Tooltips require strict adherence to the WAI-ARIA pattern:
- The trigger must have
aria-describedbypointing to the tooltip ID. - The tooltip element must possess
role="tooltip". - It must appear on both hover and keyboard focus.
Implementation
import React, { useId, useState } from 'react';
import {
useFloating,
useInteractions,
useHover,
useFocus,
useDismiss,
useRole,
offset,
flip,
shift,
safePolygon,
FloatingPortal,
} from '@floating-ui/react';
interface TooltipProps {
label: React.ReactNode;
children: React.ReactNode;
placement?: 'top' | 'bottom' | 'left' | 'right';
}
export function Tooltip({ label, children, placement = 'top' }: TooltipProps) {
const [isOpen, setIsOpen] = useState(false);
const id = useId();
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: setIsOpen,
placement,
middleware: [
offset(8),
flip(),
shift({ padding: 8 }),
],
});
const hover = useHover(context, {
move: false,
restMs: 150,
handleClose: safePolygon(), // Allows diagonal mouse movement to tooltip
});
const focus = useFocus(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: 'tooltip' });
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
focus,
dismiss,
role,
]);
return (
<>
{React.cloneElement(children as React.ReactElement,
getReferenceProps({
ref: refs.setReference,
'aria-describedby': isOpen ? id : undefined,
...((children as React.ReactElement).props || {}),
})
)}
{isOpen && (
<FloatingPortal>
<div
{...getFloatingProps({
ref: refs.setFloating,
style: {
...floatingStyles,
background: '#1a1a1a',
color: '#fff',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
pointerEvents: 'none', // Crucial: prevents tooltip from stealing mouse focus
zIndex: 9999,
},
})}
id={id}
role="tooltip"
>
{label}
</div>
</FloatingPortal>
)}
</>
);
}
Key Accessibility & UX Details in the Tooltip
pointerEvents: 'none': Because tooltips are non-interactive, disabling pointer events ensures the user’s mouse won’t accidentally get caught on the tooltip container, causing jittery hover states.safePolygon(): Floating UI providessafePolygoninsideuseHover. Without this, if a user moves their mouse from the trigger toward the floating element through the gap, the cursor momentarily exits both elements, triggering a close event.safePolygoncreates an invisible trapezoidal boundary allowing smooth traversal.- Conditional
aria-describedby: We only injectaria-describedbywhenisOpenis true, keeping the accessibility tree clean when the tooltip is hidden.
Building a Robust Popover Component
Unlike tooltips, popovers contain interactive content. They require click triggers, focus trapping (optional depending on UX design, but essential for modals/dialogs), explicit keyboard navigation, and reliable escape-key handling.
Let’s build a modular Popover component using compound components (<Popover>, <PopoverTrigger>, <PopoverContent>).
import React, { useState, useId, createContext, useContext } from 'react';
import {
useFloating,
useClick,
useDismiss,
useRole,
useInteractions,
offset,
flip,
shift,
FloatingPortal,
} from '@floating-ui/react';
interface PopoverContextType {
open: boolean;
setOpen: (open: boolean) => void;
getReferenceProps: (userProps?: React.HTMLProps<Element>) => Record<string, any>;
getFloatingProps: (userProps?: React.HTMLProps<Element>) => Record<string, any>;
refs: any;
floatingStyles: React.CSSProperties;
labelId: string;
descriptionId: string;
}
const PopoverContext = createContext<PopoverContextType | null>(null);
export function Popover({ children, placement = 'bottom-start' }: { children: React.ReactNode; placement?: any }) {
const [open, setOpen] = useState(false);
const labelId = useId();
const descriptionId = useId();
const { refs, floatingStyles, context } = useFloating({
open,
onOpenChange: setOpen,
placement,
middleware: [offset(6), flip(), shift({ padding: 10 })],
});
const click = useClick(context);
const dismiss = useDismiss(context, {
escapeKey: true,
outsidePress: true,
});
const role = useRole(context, { role: 'dialog' });
const { getReferenceProps, getFloatingProps } = useInteractions([
click,
dismiss,
role,
]);
return (
<PopoverContext.Provider
value={{
open,
setOpen,
getReferenceProps,
getFloatingProps,
refs,
floatingStyles,
labelId,
descriptionId,
}}
>
{children}
</PopoverContext.Provider>
);
}
Creating the Trigger and Content Sub-Components
export function PopoverTrigger({ children }: { children: React.ReactElement }) {
const { refs, getReferenceProps, open } = useContext(PopoverContext)!;
return React.cloneElement(children,
getReferenceProps({
ref: refs.setReference,
'aria-expanded': open,
...children.props,
})
);
}
export function PopoverContent({ children }: { children: React.ReactNode }) {
const { open, refs, floatingStyles, getFloatingProps, labelId } = useContext(PopoverContext)!;
if (!open) return null;
return (
<FloatingPortal>
<div
{...getFloatingProps({
ref: refs.setFloating,
style: {
...floatingStyles,
background: '#ffffff',
color: '#000000',
border: '1px solid #ccc',
borderRadius: '8px',
padding: '16px',
boxShadow: '0 10px 25px rgba(0,0,0,0.1)',
zIndex: 9999,
minWidth: '200px',
},
})}
aria-labelledby={labelId}
tabIndex={-1}
>
{children}
</div>
</FloatingPortal>
);
}
Masterclass: Handling Edge Cases
Writing the component is only half the battle. Let’s examine critical edge cases that separate amateur components from production-grade ones.
1. Escape Key Handling & Focus Restoration
When a user opens a popover and presses Escape, the popover must close and focus must immediately return to the trigger element that opened it. If focus drops back to document.body or an arbitrary point on the page, keyboard users lose their place.
Floating UI’s useDismiss hook handles escape key detection natively, and because it maintains a reference to the triggering element via context, focus restoration is handled seamlessly out of the box.
// Floating UI automatically restores focus to the reference element
const dismiss = useDismiss(context, {
escapeKey: true,
outsidePress: true,
});
2. Screen Reader Announcements for Dynamic Content
When tooltips appear, screen readers like VoiceOver or NVDA read the text bound via aria-describedby. However, popovers act as dialogs (role="dialog"). Screen readers will announce the role, the accessible name (aria-labelledby), and then read the first focusable element inside.
To ensure screen readers don’t stutter or skip content:
- Always assign a clear
aria-labelledbylinking to a header inside the popover content. - Ensure the popover container has
tabIndex={-1}so screen readers recognize it as a programmatic focus container upon opening.
3. Mobile Touch Considerations
Hover events do not exist on touchscreens. A mobile user tapping a tooltip trigger can experience confusing states where a tooltip flashes and instantly disappears.
To handle mobile gracefully, convert hover tooltips into tap-to-toggle popovers on touch devices, or disable hover triggers when window.matchMedia('(pointer: coarse)').matches is true. Floating UI allows conditional hook application:
const isTouch = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
const hover = useHover(context, { enabled: !isTouch });
const click = useClick(context, { enabled: isTouch });
Putting It All Together in an App
Here is how clean your consumer code looks when using our primitive components:
export function App() {
return (
<div style={{ padding: '100px', display: 'flex', gap: '40px' }}>
{/* Tooltip Example */}
<Tooltip label="Copy code snippet to clipboard">
<button>Copy</button>
</Tooltip>
{/* Popover Example */}
<Popover>
<PopoverTrigger>
<button>Options ⚙️</button>
</PopoverTrigger>
<PopoverContent>
<h3 id="popover-title" style={{ margin: '0 0 8px 0' }}>Settings</h3>
<p style={{ margin: '0 0 12px 0', fontSize: '14px' }}>Manage your account preferences.</p>
<button onClick={() => alert('Action clicked!')}>Profile Settings</button>
</PopoverContent>
</Popover>
</div>
);
}
Conclusion
Building accessible floating components requires meticulous attention to detail. By combining React, Floating UI, and strict adherence to WAI-ARIA authoring practices, you achieve:
- Bulletproof positioning that never clips off screen edges.
- Seamless keyboard navigation with automatic focus return on escape.
- Inclusive screen reader support via dynamic
aria-describedbyandrole="dialog"attributes. - Adaptive mobile behavior handling touch vs. pointer inputs correctly.
By treating accessibility as a foundational requirement rather than an afterthought, you create resilient user interfaces that work for everyone, everywhere.