Tooltip vs Popover in React: When to Use Each, Full Code
Tooltip or popover? React devs mix these up constantly. Here's when each pattern fits, how to build both from scratch, and full TSX code with Tailwind.
Tooltip vs Popover: They're Not the Same Thing
Honestly, most React codebases I've reviewed use these two patterns interchangeably — and that's a problem. A tooltip and a popover solve different UX problems. Conflating them produces components that confuse users and wreck accessibility.
A tooltip is a non-interactive, hover-triggered label. It reveals a short piece of text — usually under 60 characters — when the user hovers or focuses a trigger element. It disappears the moment focus or hover leaves. No clicks. No interactive content inside.
A popover is click-triggered. It can contain rich content: buttons, links, form fields, images. It stays open until explicitly dismissed. That's the defining difference — interactivity and persistence. If the floating layer needs a close button or a form, it's a popover, full stop.
When to Reach for a Tooltip in React
Tooltips belong on icon buttons, truncated text, and shortcut hints. Think the little Ctrl+K hint next to a search icon, or a label on a toolbar icon that has no visible text. The user doesn't need to interact with the floating content — they just need to understand what the trigger does.
Keep tooltip content to one short phrase. The moment you find yourself putting a link inside a tooltip, stop. That content can't be accessed on touch devices, and screen readers typically only surface tooltip text through aria-describedby — your link will be invisible to keyboard users who can't hover.
From a timing standpoint, tooltips should delay slightly before appearing — 300ms to 500ms is the sweet spot. Immediate appearance on hover is jarring when you're moving a cursor across a toolbar. A short delay filters out accidental hovers. You'll see Radix UI uses 700ms by default; I personally prefer 400ms for most interfaces.
When a Popover Is the Right Call
Popovers earn their place when you have secondary content that doesn't justify a full modal. Filter panels, color pickers, date pickers, confirmation dialogs anchored to a button — these are all popover territory. The key signal: the user has to *do* something inside the floating layer.
Unlike tooltips, popovers need explicit dismiss logic. Click outside to close, Escape key, and a visible close button are all expected. Users need to be able to navigate into the floating content with Tab, interact with it, and return. If your implementation can't handle that, reach for a modal instead.
Popovers also need a sensible focus management story. When one opens, focus should move into the panel — or at minimum, the first interactive element inside it should be reachable immediately on Tab press. This is where a lot of home-rolled implementations fall apart. Libraries like Floating UI handle this well out of the box.
Building a Tooltip Component with Tailwind v4 and React
Here's a minimal but production-worthy tooltip in React. It uses useState, onMouseEnter/onMouseLeave, and onFocus/onBlur so it works for both mouse and keyboard users. Styling is pure Tailwind v4.0.2 with a custom rgba background for the floating tip.
import { useState, useRef, useId } from 'react';
interface TooltipProps {
content: string;
children: React.ReactElement;
delayMs?: number;
}
export function Tooltip({ content, children, delayMs = 400 }: TooltipProps) {
const [visible, setVisible] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const tooltipId = useId();
const show = () => {
timerRef.current = setTimeout(() => setVisible(true), delayMs);
};
const hide = () => {
if (timerRef.current) clearTimeout(timerRef.current);
setVisible(false);
};
return (
<span className="relative inline-flex">
{/* Clone child to inject aria-describedby + event handlers */}
{React.cloneElement(children, {
'aria-describedby': visible ? tooltipId : undefined,
onMouseEnter: show,
onMouseLeave: hide,
onFocus: show,
onBlur: hide,
})}
{visible && (
<span
id={tooltipId}
role="tooltip"
className="
pointer-events-none absolute bottom-full left-1/2
-translate-x-1/2 mb-2 z-50
rounded-md px-2.5 py-1.5 text-xs font-medium
whitespace-nowrap text-white
shadow-md
"
style={{ background: 'rgba(15,15,20,0.92)' }}
>
{content}
{/* Arrow */}
<span
className="absolute top-full left-1/2 -translate-x-1/2"
style={{
width: 0,
height: 0,
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderTop: '5px solid rgba(15,15,20,0.92)',
}}
/>
</span>
)}
</span>
);
}Notice pointer-events-none on the tooltip span. Without that, the tooltip flickers when you move the cursor from the trigger onto the floating tip itself — the mouse-leave fires and kills it. This is a subtle but critical detail that trips up a lot of first implementations.
Building a Popover Component in React with Floating UI
For positioning logic, don't reinvent the wheel. Floating UI (@floating-ui/react, currently at v0.26.x) handles viewport collision detection, offset, and arrow positioning far better than any hand-rolled getBoundingClientRect approach. The setup feels verbose at first but it's worth it.
import {
useFloating,
useClick,
useDismiss,
useInteractions,
FloatingPortal,
FloatingFocusManager,
offset,
flip,
shift,
arrow,
} from '@floating-ui/react';
import { useState, useRef } from 'react';
interface PopoverProps {
trigger: React.ReactElement;
children: React.ReactNode;
}
export function Popover({ trigger, children }: PopoverProps) {
const [open, setOpen] = useState(false);
const arrowRef = useRef<HTMLDivElement>(null);
const { refs, floatingStyles, context, middlewareData } = useFloating({
open,
onOpenChange: setOpen,
placement: 'bottom-start',
middleware: [
offset(8), // 8px gap between trigger and panel
flip(),
shift({ padding: 12 }),
arrow({ element: arrowRef }),
],
});
const click = useClick(context);
const dismiss = useDismiss(context);
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]);
return (
<>
{React.cloneElement(trigger, {
ref: refs.setReference,
...getReferenceProps(),
})}
{open && (
<FloatingPortal>
<FloatingFocusManager context={context} modal={false}>
<div
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
className="
z-50 rounded-xl border border-white/10
bg-neutral-900 p-4 shadow-xl
text-sm text-neutral-100
min-w-[220px]
"
>
{children}
<div
ref={arrowRef}
className="absolute h-2 w-2 rotate-45 bg-neutral-900 border-l border-t border-white/10"
style={{
left: middlewareData.arrow?.x ?? '',
top: middlewareData.arrow?.y ?? '',
}}
/>
</div>
</FloatingFocusManager>
</FloatingPortal>
)}
</>
);
}The FloatingFocusManager with modal={false} is doing heavy lifting here. It traps focus inside the popover while still letting users interact with the rest of the page — which is what you want for non-modal overlays. Set modal={true} only if you want full focus trapping like a dialog.
Accessibility: aria-describedby vs aria-expanded
Tooltip and popover each have a distinct ARIA pattern. Tooltips use role="tooltip" on the floating element and aria-describedby on the trigger pointing to that tooltip's id. Screen readers will read the tooltip content after the button label — not instead of it. That's by design.
Popovers use aria-expanded on the trigger button to signal open/closed state. The floating panel itself usually carries role="dialog" or no explicit role if it's a simple menu-like panel. You can also use aria-haspopup="dialog" on the trigger if your popover contains dialog-level content. Don't stack both aria-describedby and aria-expanded on the same element — pick the pattern that matches what your component actually is.
What about mobile? Touch devices don't have hover. A tooltip that only fires on mouseenter is invisible to mobile users. If you need the information to be accessible on touch, you have two options: embed it in a visible label, or convert the tooltip to a popover that fires on tap. There's no shame in that. Accessibility isn't optional.
Common Mistakes and How to Avoid Them
The biggest mistake: using display: none to toggle visibility instead of conditional rendering or CSS opacity transitions. display: none elements don't animate. You can't fade them in. Use opacity + pointer-events toggling with a CSS transition, or conditionally render and use a library like Framer Motion for entry/exit. If you're building animated UI components like tabs, you already know the drill.
Another one: forgetting to handle the Escape key. It's expected behavior that pressing Escape closes any floating layer — tooltip or popover. For popovers, Floating UI's useDismiss hook handles this automatically. For tooltips, you should add an onKeyDown listener to the trigger that calls hide() on Escape. This matters for users navigating via keyboard.
Positioning drift is a third common issue. If your trigger is inside a scroll container or a transformed parent, position: absolute on the tooltip will produce wrong coordinates. Use position: fixed with computed offsets from getBoundingClientRect(), or just use Floating UI which accounts for all of this. Looking at how glassmorphism cards are positioned gives you a sense of how layering and positioning interact in complex UIs.
Finally: z-index wars. If you're not using a portal (ReactDOM.createPortal or Floating UI's FloatingPortal), your tooltip or popover is constrained by its parent's stacking context. A parent with transform, filter, or will-change creates a new stacking context that breaks fixed positioning. Portal-render everything floating.
Integrating with Empire UI's Style System
Empire UI ships 40 visual styles, and both tooltip and popover variants pick up the active theme automatically. The trick is using CSS custom properties instead of hard-coded color values. Something like var(--surface-overlay) for the background and var(--border-subtle) for the border means your component adapts across Glassmorphism, Neumorphism, Cyberpunk, and every other style without a single prop change.
If you're combining these with other motion-heavy components — say, an animated button that triggers a popover, or a card stack where each card has tooltip labels on its actions — you'll want to coordinate z-index layers. A simple scale: z-10 for card overlays, z-40 for popovers, z-50 for tooltips. Stick to that and you won't spend an afternoon debugging layering bugs.
One thing worth knowing: the Empire UI tooltip component supports a variant prop with values 'default', 'info', 'warning', and 'error'. Each maps to a different background token. The 'warning' variant uses rgba(234,179,8,0.15) with an amber border — ideal for form field hints where you need to signal caution without blocking the UI.
FAQ
Technically yes, but I'd advise against it. The two patterns have fundamentally different ARIA requirements — tooltips use role="tooltip" and aria-describedby, while popovers use aria-expanded and role="dialog". Merging them into one component usually means you end up with incorrect ARIA on at least one of the two use cases. Keep them as separate components.
You're missing pointer-events-none on the tooltip element. Without it, moving the cursor onto the floating tip triggers mouseleave on the trigger, which hides the tooltip. Add pointer-events: none (or the Tailwind class pointer-events-none) to the tooltip span and the flicker stops.
Yes, almost always. Without a portal, the popover is constrained by its parent's stacking context. If any ancestor has transform, filter, opacity, or will-change, position: fixed breaks and your popover ends up offset or clipped. Portal-rendering to document.body avoids all of this. Floating UI's FloatingPortal makes it one line.
Between 300ms and 500ms for most interfaces. 400ms is a reasonable default. No delay makes tooltips feel jittery as you move across the UI. A delay longer than 700ms makes them feel sluggish. You can expose delayMs as a prop so consumers can tune per-context — toolbar icons might want 300ms while form field hints might want 600ms.
You can't rely on hover for touch. Options: add an onClick handler that toggles visibility on tap (which makes it behave more like a popover), use longpress events, or simply make the label visible inline instead of hiding it behind a tooltip. For icon-only buttons, the best accessible solution on touch is often a visible text label, not a tooltip at all.
Yes. Radix @radix-ui/react-tooltip fires on both hover and focus, includes role="tooltip" and aria-describedby wiring, and handles Escape dismissal. For popovers, @radix-ui/react-popover handles aria-expanded, focus management, and outside-click dismissal. If you're not building your own from scratch, these are solid choices — just make sure you're on a current version (Tooltip is at 1.1.x as of late 2026).