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.

ASOasis
7 min read
Building an Accessible React Popover and Tooltip Component

Image used for representation purposes only.

Overview

Tooltips and popovers solve a similar problem—displaying contextual information anchored to another element—but they serve different moments in the UI. A tooltip offers brief, non-interactive assistance on hover or focus. A popover presents richer, interactive content (forms, menus, confirmations) on click or press and must manage focus, dismissal, and layering more carefully.

In this article, you’ll learn the UX differences, accessibility requirements, positioning techniques, and an implementation using @floating-ui/react for both a tooltip and a popover. We’ll close with testing guidance, performance tips, and common pitfalls.

Tooltip vs. Popover: When to Use Each

  • Tooltip

    • Purpose: Clarify an icon, explain a field, disclose keyboard shortcuts.
    • Trigger: Hover with a short delay; also on keyboard focus.
    • Content: Short, non-interactive text only.
    • Dismissal: On pointer out, blur, or Esc.
    • A11y: Announce via aria-describedby; avoid trapping focus.
  • Popover

    • Purpose: Present interactive UI: settings, pickers, confirmations.
    • Trigger: Click/press. Avoid hover for interactive content.
    • Content: Any interactive controls; keep it concise.
    • Dismissal: Outside click, Esc, or explicit close action.
    • A11y: role=“dialog” or role=“menu” (depending on semantics), proper labelling, focus management.

Core Architecture

Regardless of component type, a robust floating UI usually includes:

  • Reference element: The anchor (e.g., a button or icon).
  • Floating element: The tooltip/popover surface.
  • Positioning engine: Computes x/y, placement, collision handling, and arrows.
  • Layering: Portals to avoid overflow clipping and z-index conflicts.
  • Interactions: Hover/focus/click handling, outside-press detection, Esc to close.
  • Accessibility: ARIA attributes, keyboard support, and focus management (popover only).

Accessibility Essentials

  • Tooltip

    • Reference uses aria-describedby=“tooltip-id” when the tooltip is visible.
    • Don’t place focusable controls inside tooltips.
    • Show on focus for keyboard users; hide on blur.
    • Consider a small open delay and instant close to reduce flicker.
  • Popover

    • Trigger should be a real button with aria-expanded and aria-controls.
    • Floating surface: role=“dialog” with aria-labelledby and/or aria-describedby.
    • Move focus into the popover on open; return focus to the trigger on close.
    • Support Esc to close and outside-press dismissal.

Implementation With @floating-ui/react (TypeScript)

Below are compact, production-ready patterns using @floating-ui/react. They demonstrate positioning, interactions, portals, and focus management.

Tooltip Component

// Tooltip.tsx
import React from 'react';
import {
  useFloating,
  offset,
  flip,
  shift,
  arrow,
  autoUpdate,
  useHover,
  useFocus,
  useDismiss,
  useRole,
  useInteractions,
  FloatingPortal,
  FloatingArrow,
} from '@floating-ui/react';

interface TooltipProps {
  label: string;
  children: React.ReactElement; // the trigger element
  placement?: Parameters<typeof useFloating>[0]['placement'];
  openDelay?: number;
}

export function Tooltip({ label, children, placement = 'top', openDelay = 150 }: TooltipProps) {
  const [open, setOpen] = React.useState(false);
  const arrowRef = React.useRef<SVGSVGElement | null>(null);

  const {refs, floatingStyles, context} = useFloating({
    placement,
    open,
    onOpenChange: setOpen,
    middleware: [offset(8), flip(), shift({padding: 8}), arrow({element: arrowRef})],
    whileElementsMounted: autoUpdate,
  });

  // Interactions: hover + focus for a11y; dismiss via pointer out / Esc
  const hover = useHover(context, {move: false, delay: {open: openDelay, close: 0}});
  const focus = useFocus(context);
  const dismiss = useDismiss(context);
  const role = useRole(context, {role: 'tooltip'});
  const {getReferenceProps, getFloatingProps} = useInteractions([hover, focus, dismiss, role]);

  // Merge refs on the trigger
  const trigger = React.cloneElement(children, {
    ref: (node: HTMLElement) => {
      refs.setReference(node);
      const {ref} = children as any;
      if (typeof ref === 'function') ref(node);
      else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;
    },
    'aria-describedby': open ? 'tooltip-' + context.floatingId : undefined,
    ...getReferenceProps(),
  });

  return (
    <>
      {trigger}
      <FloatingPortal>
        {open && (
          <div
            id={'tooltip-' + context.floatingId}
            ref={refs.setFloating}
            style={floatingStyles}
            {...getFloatingProps({
              className:
                'pointer-events-none select-none rounded-md bg-neutral-900 px-2 py-1 text-sm text-white shadow-lg',
            })}
          >
            {label}
            <FloatingArrow ref={arrowRef} context={context} fill="#171717" />
          </div>
        )}
      </FloatingPortal>
    </>
  );
}

Usage:

<Tooltip label="Edit">
  <button aria-label="Edit">
    <svg aria-hidden="true">...</svg>
  </button>
</Tooltip>

Notes:

  • The tooltip renders in a portal to avoid clipping.
  • aria-describedby is applied only while open, preventing stale associations.
  • Avoid tooltips for disabled controls; disabled elements can’t be focused.

Popover Component

// Popover.tsx
import React from 'react';
import {
  useFloating,
  offset,
  flip,
  shift,
  autoUpdate,
  useClick,
  useDismiss,
  useRole,
  useInteractions,
  FloatingPortal,
  FloatingFocusManager,
} from '@floating-ui/react';

interface PopoverProps {
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  label?: string; // used for aria-labelledby if provided
  placement?: Parameters<typeof useFloating>[0]['placement'];
  children: {
    trigger: React.ReactElement;
    content: React.ReactNode;
  };
}

export function Popover({
  open: controlledOpen,
  onOpenChange,
  placement = 'bottom-start',
  label,
  children,
}: PopoverProps) {
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
  const open = controlledOpen ?? uncontrolledOpen;
  const setOpen = onOpenChange ?? setUncontrolledOpen;

  const {refs, floatingStyles, context} = useFloating({
    placement,
    open,
    onOpenChange: setOpen,
    whileElementsMounted: autoUpdate,
    middleware: [offset(8), flip(), shift({padding: 8})],
  });

  const click = useClick(context, {toggle: true});
  const dismiss = useDismiss(context, {outsidePress: true, escapeKey: true});
  const role = useRole(context, {role: 'dialog'});
  const {getReferenceProps, getFloatingProps} = useInteractions([click, dismiss, role]);

  const triggerId = React.useId();
  const labelId = label ? `${triggerId}-label` : undefined;

  const trigger = React.cloneElement(children.trigger, {
    ref: (node: HTMLElement) => {
      refs.setReference(node);
      const {ref} = children.trigger as any;
      if (typeof ref === 'function') ref(node);
      else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;
    },
    id: triggerId,
    'aria-haspopup': 'dialog',
    'aria-expanded': open,
    'aria-controls': open ? context.floatingId : undefined,
    ...getReferenceProps(),
  });

  return (
    <>
      {trigger}
      <FloatingPortal>
        {open && (
          <FloatingFocusManager context={context} modal={true} returnFocus={true}>
            <div
              ref={refs.setFloating}
              id={context.floatingId}
              aria-labelledby={labelId}
              style={floatingStyles}
              {...getFloatingProps({
                className:
                  'w-64 rounded-lg border border-neutral-200 bg-white p-3 shadow-xl outline-none [--ring:0_0_0_2px_rgba(59,130,246,0.5)] focus-visible:ring-[--ring]',
              })}
            >
              {label && (
                <div id={labelId} className="mb-2 text-sm font-medium text-neutral-900">
                  {label}
                </div>
              )}
              {children.content}
            </div>
          </FloatingFocusManager>
        )}
      </FloatingPortal>
    </>
  );
}

Usage:

<Popover label="Quick settings">
  {{
    trigger: <button type="button">Open</button>,
    content: (
      <form className="space-y-3">
        <label className="flex items-center gap-2">
          <input type="checkbox" /> Email alerts
        </label>
        <button type="button" className="rounded bg-blue-600 px-3 py-1 text-white">Save</button>
      </form>
    ),
  }}
</Popover>

Notes:

  • FloatingFocusManager traps focus inside the dialog-like surface and returns focus to the trigger on close.
  • aria-expanded and aria-controls on the trigger communicate state to assistive tech.
  • Use controlled props (open, onOpenChange) to integrate with application state when necessary.

Styling and Motion

  • Keep tooltips subtle: small radius, compact padding, and high-contrast text.
  • Popovers should resemble lightweight dialogs: elevation, border, and adequate spacing.
  • Use CSS transitions for opacity/scale. Prefer transform/opacity for perf; avoid animating layout.
  • Provide reduced motion fallbacks via prefers-reduced-motion.

Example transition snippet:

.popover-enter { opacity: 0; transform: scale(0.98); }
.popover-enter-active { opacity: 1; transform: scale(1); transition: 120ms ease-out; }
.popover-exit { opacity: 1; }
.popover-exit-active { opacity: 0; transition: 80ms ease-in; }

Touch and Mobile Considerations

  • Tooltips: Many mobile platforms lack hover. Prefer always-visible labels, or show on focus/long-press with caution.
  • Popovers: Ensure large targets and safe areas. Close on backdrop tap and Esc (hardware keyboards).
  • Avoid obstructing essential content; use placement strategies (flip and shift) with ample padding.

Performance Tips

  • Lazy-mount content: render the floating element only when open.
  • Use autoUpdate from Floating UI to efficiently re-compute position on scroll/resize.
  • Keep popover content light; avoid heavy portals inside portals.
  • If rendering many triggers in a list, memoize triggers and defer expensive measurements until open.

Testing Strategy

  • Unit/integration (React Testing Library)
    • Verify ARIA attributes (aria-describedby, aria-expanded, aria-controls).
    • Ensure keyboard interactions: Tab to trigger, Enter/Space to open popover, Esc to close.
    • Assert focus movement with FloatingFocusManager.
  • End-to-end (Playwright/Cypress)
    • Outside click closes popover.
    • Placement remains correct when scrolling.
    • Tooltip appears on focus and hides on blur.

Common Pitfalls and How to Avoid Them

  • Tooltip on disabled buttons: Disabled elements aren’t focusable; wrap the button in a span and attach the tooltip to that wrapper, or avoid tooltip.
  • Interactive tooltip content: Don’t. Use a popover instead.
  • Z-index and clipping: Use portals; ensure parent containers don’t clip overflow.
  • Scrollable containers: Prefer portal-to-body. If you must stay in-container, enable autoUpdate and handle collision with flip/shift.
  • Focus loss when remounting triggers: Keep trigger stable (avoid conditional remounts) so returning focus works reliably.

Alternatives and When to Choose Them

  • Radix UI Popover/Tooltip: Accessible, unstyled primitives with strong a11y defaults and consistent APIs.
  • Headless UI Popover: Tailwind-friendly, headless and composable; no tooltip primitive.
  • Component libraries (MUI, Chakra, Mantine): Faster delivery with opinionated styling and theming. Choose primitives (Floating UI / Radix) when you need granular control; choose a full library when you want velocity and a coherent design system.

Production Checklist

  • Tooltip shows on hover and focus; hides on blur and Esc.
  • Popover manages focus, supports Esc/outside-press, and returns focus.
  • Correct ARIA roles and labelling.
  • Portals used to avoid clipping; layering verified across the app.
  • Motion respects prefers-reduced-motion.
  • Works with keyboard-only and screen readers; passes automated a11y checks.

Conclusion

Tooltips and popovers are small but high-impact details in React apps. By combining a solid positioning engine, accurate ARIA semantics, and predictable interactions, you can ship components that feel native, accessible, and resilient. Start with the patterns above, adapt styling to your design system, and lock in behavior with tests. Your users—and your future self—will thank you.

Related Posts