Building an Accessible React Tooltip and Hover Card in React
Build an accessible React tooltip and hover card with Floating UI and Radix, covering keyboard support, touch, portals, theming, and tests.
Image used for representation purposes only.
Overview
Tooltips and hover cards are small, contextual layers that appear near a trigger element to explain, hint, or preview. A tooltip is brief and non-interactive; a hover card is richer and often interactive (links, buttons, previews). In React, the best implementations are accessible by keyboard, work on touch, position reliably, and perform well.
This guide shows two approaches:
- From-scratch, headless components powered by Floating UI for positioning and interactions.
- A fast path using Radix UI primitives when you want a robust, production-ready baseline.
We will cover roles and ARIA, behavior on hover/focus/touch, portals, theming, motion, and testing.
UX and accessibility essentials
Before you write code, capture the contract of good behavior:
-
Tooltip vs hover card
- Tooltip: short text label or hint; non-interactive; appears on hover and focus. Use role=‘tooltip’ and link it with aria-describedby.
- Hover card: richer, may include interactive content; appears on hover or focus and should remain open while cursor or focus moves inside it. It is not modal; treat it like a non-modal popover. Use aria-haspopup=‘dialog’ and role=‘dialog’ for the floating content; manage Escape to close.
-
Keyboard support
- Open on focus; close on blur or Escape.
- Tooltip content should not receive focus. Hover card content can receive focus and should not trap focus.
-
Timing
- Use a small open delay (e.g., 300–500 ms) to avoid flicker; close faster (100–150 ms). For hover cards, use safe polygon style pointer handling so quick diagonal moves into the card do not close it.
-
Touch and mobile
- Tooltips are weak on touch. Prefer an info icon that toggles a popover/hover card on tap or long-press. Ensure dismiss via outside tap or Escape on physical keyboards.
-
Placement and boundaries
- Prefer top or bottom with automatic flipping and shifting to keep within viewport and scroll containers. Provide an arrow when helpful.
-
Announce semantics
- Tooltip: role=‘tooltip’, tied to a single trigger via aria-describedby with a unique id.
- Hover card: role=‘dialog’ on content and aria-labelledby or aria-label; trigger may expose aria-expanded and aria-haspopup=‘dialog’.
Architecture: headless + positioning
Positioning popovers is non-trivial: clipping, scrolling, collisions, and right-to-left. A headless positioning engine like Floating UI handles this via middleware (offset, flip, shift, arrow) and interaction hooks (hover, focus, dismiss). Render the floating layer in a portal to escape overflow and stacking issues.
Tooltip component with Floating UI
Below is a compact, accessible, headless Tooltip in TypeScript using @floating-ui/react.
import React, {useId, useMemo, cloneElement, useState} from 'react';
import {
useFloating,
offset,
flip,
shift,
arrow as arrowMw,
useHover,
useFocus,
useDismiss,
useRole,
useInteractions,
autoUpdate,
FloatingPortal,
} from '@floating-ui/react';
function mergeRefs<T>(...refs: (React.Ref<T> | undefined)[]) {
return (node: T) => refs.forEach(r => {
if (!r) return;
if (typeof r === 'function') r(node);
else (r as any).current = node;
});
}
export type TooltipProps = {
content: React.ReactNode;
children: React.ReactElement;
placement?: Parameters<typeof useFloating>[0]['placement'];
delay?: number | { open: number; close: number };
open?: boolean;
onOpenChange?: (o: boolean) => void;
disabled?: boolean;
className?: string; // for the bubble
arrowClassName?: string;
};
export function Tooltip({
content,
children,
placement = 'top',
delay = { open: 400, close: 100 },
open: controlledOpen,
onOpenChange,
disabled,
className,
arrowClassName,
}: TooltipProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const open = controlledOpen ?? uncontrolledOpen;
const setOpen = onOpenChange ?? setUncontrolledOpen;
const id = useId();
const arrowRef = React.useRef<HTMLDivElement | null>(null);
const {refs, floatingStyles, context, placement: actualPlacement, middlewareData} = useFloating({
open,
onOpenChange: setOpen,
placement,
whileElementsMounted: autoUpdate,
middleware: [offset(6), flip(), shift({ padding: 8 }), arrowMw({ element: arrowRef })],
});
const hover = useHover(context, { delay: typeof delay === 'number' ? { open: delay, close: 100 } : delay, move: false, enabled: !disabled });
const focus = useFocus(context, { enabled: !disabled });
const dismiss = useDismiss(context, { ancestorScroll: true });
const role = useRole(context, { role: 'tooltip' });
const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role]);
const child = React.Children.only(children) as React.ReactElement;
const withAria = {
'aria-describedby': open ? id : undefined,
} as const;
const staticSide = useMemo(() => {
const side = actualPlacement.split('-')[0] as 'top' | 'right' | 'bottom' | 'left';
return { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[side];
}, [actualPlacement]);
return (
<>
{cloneElement(child, getReferenceProps({
ref: mergeRefs((child as any).ref, refs.setReference),
...child.props,
...withAria,
}))}
<FloatingPortal>
{open && (
<div
id={id}
ref={refs.setFloating}
style={floatingStyles}
className={className ?? 'tooltip'}
data-state={open ? 'open' : 'closed'}
{...getFloatingProps()}
>
{content}
<div
ref={arrowRef}
className={arrowClassName ?? 'tooltip-arrow'}
style={{
position: 'absolute',
left: middlewareData.arrow?.x ?? '',
top: middlewareData.arrow?.y ?? '',
[staticSide!]: '-4px',
}}
/>
</div>
)}
</FloatingPortal>
</>
);
}
Minimal styles (CSS)
Use CSS variables for easy theming and data attributes for motion states.
.tooltip {
--bg: #111;
--fg: #fff;
--radius: 8px;
--shadow: 0 6px 24px rgba(0,0,0,.16), 0 2px 8px rgba(0,0,0,.12);
background: var(--bg);
color: var(--fg);
border-radius: var(--radius);
font: 500 12px/1.2 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
padding: 8px 10px;
box-shadow: var(--shadow);
max-width: 280px;
z-index: 1000;
transform-origin: var(--transform-origin, center);
animation: tooltip-in 150ms ease-out;
}
.tooltip[data-state='closed'] { animation: tooltip-out 120ms ease-in forwards; }
.tooltip-arrow {
width: 8px; height: 8px; transform: rotate(45deg); background: var(--bg); box-shadow: var(--shadow);
}
@keyframes tooltip-in { from { opacity: 0; transform: translateY(2px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes tooltip-out { to { opacity: 0; transform: translateY(2px) scale(.98); } }
Usage
<Tooltip content='Copies the link to your clipboard'>
<button type='button'>Copy link</button>
</Tooltip>
Hover card with safe polygon navigation
A hover card stays open while the pointer moves from trigger to card. Floating UI provides safePolygon to avoid accidental closes during diagonal movement.
import React from 'react';
import {
useFloating,
offset,
flip,
shift,
useHover,
useFocus,
useDismiss,
useRole,
useInteractions,
autoUpdate,
FloatingPortal,
safePolygon,
} from '@floating-ui/react';
export function HoverCard({
trigger,
children,
placement = 'right',
open: controlledOpen,
onOpenChange,
}: {
trigger: React.ReactElement;
children: React.ReactNode; // interactive content
placement?: any;
open?: boolean;
onOpenChange?: (o: boolean) => void;
}) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
const open = controlledOpen ?? uncontrolledOpen;
const setOpen = onOpenChange ?? setUncontrolledOpen;
const {refs, floatingStyles, context} = useFloating({
open,
onOpenChange: setOpen,
placement,
whileElementsMounted: autoUpdate,
middleware: [offset(10), flip(), shift({ padding: 8 })],
});
const hover = useHover(context, {
handleClose: safePolygon({ restMs: 40 }),
move: true,
delay: { open: 200, close: 100 },
});
const focus = useFocus(context);
const dismiss = useDismiss(context, { referencePress: true, escapeKey: true });
const role = useRole(context, { role: 'dialog' });
const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role]);
return (
<>
{React.cloneElement(trigger, getReferenceProps({ ref: refs.setReference, ...trigger.props, 'aria-haspopup': 'dialog', 'aria-expanded': open ? 'true' : 'false' }))}
<FloatingPortal>
{open && (
<div
ref={refs.setFloating}
style={floatingStyles}
role='dialog'
aria-label='Account preview'
className='hover-card'
{...getFloatingProps({ tabIndex: -1 })}
>
{children}
</div>
)}
</FloatingPortal>
</>
);
}
Example content
<HoverCard
trigger={<a href='#' onClick={e => e.preventDefault()}>@alex</a>}
>
<section style={{ width: 280 }}>
<header style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<img src='/avatar.png' alt='' width={40} height={40} style={{ borderRadius: 999 }} />
<div>
<strong>Alex Morgan</strong>
<div style={{ color: '#666', fontSize: 12 }}>Staff Engineer</div>
</div>
</header>
<p style={{ marginTop: 8 }}>Working on platform reliability and DX.</p>
<footer style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<button type='button'>Follow</button>
<button type='button'>Message</button>
</footer>
</section>
</HoverCard>
Motion, theming, and design tokens
- Drive color, radius, and shadow using CSS variables to support light/dark themes.
- For animation, use CSS keyframes for simplicity; for synchronized entrance/exit across components, consider a small Framer Motion wrapper.
- Respect reduced motion: wrap animations with a prefers-reduced-motion media query.
@media (prefers-reduced-motion: reduce) {
.tooltip { animation: none; }
}
Controlled vs uncontrolled
- Uncontrolled: simplest; state is internal, good for most tooltips and hover cards.
- Controlled: pass open and onOpenChange to drive state from Redux, Zustand, or URL. This enables advanced patterns (e.g., keep card open while a related panel is active).
Portals and stacking context
Render floating layers in a portal to avoid clipping by overflow or transform contexts. If you must contain inside a scroller, add shift middleware padding so content does not clip. Keep z-index tokens consistent across toasts, modals, and popovers.
Mobile and long-press
For touch devices, replace hover with press. Use a press or long-press gesture to toggle an informational popover. Always provide a close affordance (X) and dismiss on outside press and Escape.
Testing
- Unit and integration: React Testing Library userEvent.hover, userEvent.unhover, userEvent.tab, and keyboard(’{Escape}’). Assert aria-describedby links and roles.
- E2E: Playwright or Cypress to verify placement, dismissal on scroll, and collision handling.
- Accessibility: run axe in CI to catch role/id/labeling regressions.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('opens on hover and links by aria-describedby', async () => {
const user = userEvent.setup();
render(<Tooltip content='Helpful text'><button type='button'>Trigger</button></Tooltip>);
await user.hover(screen.getByRole('button', { name: 'Trigger' }));
const tip = await screen.findByText('Helpful text');
const btn = screen.getByRole('button', { name: 'Trigger' });
expect(btn).toHaveAttribute('aria-describedby', tip.id);
});
The quick path with Radix UI
If you want a highly polished baseline without building from primitives, Radix UI provides accessible Tooltip and Hover Card primitives that handle most edge cases, including collision, delays, and safe polygons.
import * as Tooltip from '@radix-ui/react-tooltip';
<Tooltip.Provider delayDuration={400} skipDelayDuration={100}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button type='button'>Hover me</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side='top' sideOffset={6} className='tooltip'>
Short helper text
<Tooltip.Arrow className='tooltip-arrow' />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
import * as HoverCard from '@radix-ui/react-hover-card';
<HoverCard.Root openDelay={200} closeDelay={100}>
<HoverCard.Trigger asChild>
<a href='#' onClick={e => e.preventDefault()}>@alex</a>
</HoverCard.Trigger>
<HoverCard.Portal>
<HoverCard.Content side='right' sideOffset={10} className='hover-card' role='dialog'>
{/* interactive preview */}
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
Performance tips
- Reuse a single provider (e.g., Radix Tooltip.Provider) to share delay state across many triggers.
- Memoize heavy card content or fetch lazily after open to avoid blocking the main thread.
- Avoid unnecessary re-renders by keeping open state close to the component and not passing unstable props.
- Prefer CSS animations over JS when possible; they offload work to the compositor.
Common pitfalls and fixes
- Tooltip on disabled buttons: browsers prevent pointer events on disabled buttons. Wrap the button in a span and attach the tooltip to the wrapper.
- Overflow clipping: use portals and avoid overflow: hidden on ancestors when possible. When unavoidable, confine within the scroll container and add shift padding.
- Focus loss on close: if a hover card closes while focus is inside, return focus to the trigger.
- Arrow mismatch: compute arrow position from middleware and set the static side to align with placement.
Putting it all together
With a headless Tooltip and HoverCard, you own the markup, motion, and styling while delegating the math and tricky interactions to a battle-tested positioning layer. For teams that want speed and guardrails, component libraries like Radix UI offer excellent accessibility and behavior out of the box. Whichever route you choose, prioritize predictable keyboard behavior, clear semantics, and responsive performance.
Related Posts
Building an Accessible React Popover and Tooltip Component
Build accessible, high-performance React tooltip and popover components with Floating UI. Includes TypeScript examples, a11y guidance, and testing tips.
Building an Accessible React Floating Action Button (FAB) in React
Build an accessible React Floating Action Button (FAB) with portals, speed dial, theming, keyboard support, and tests—production-ready patterns included.
Build a Rock-Solid React Scroll Spy Navigation with Hooks and TypeScript
Build a robust React scroll spy navigation with hooks, a11y, smooth scrolling, SSR tips, and TypeScript—plus an optional IntersectionObserver version.