All posts
20 Sep 2026

Hover, Focus, and Escape: Building an Accessible Tooltip and Popover Component in React

Learn how to build a robust, WAI-ARIA compliant tooltip and popover component in React using Floating UI, complete with keyboard dismissal, screen reader announcements, and precise positioning.

Hover, Focus, and Escape: Building an Accessible Tooltip and Popover Component in React

Tooltips and popovers are ubiquitous interface patterns. We use tooltips to provide brief, contextual hints for icons and buttons, and popovers to present richer content like menus, filters, or form controls. However, building them correctly is surprisingly difficult.

A poorly implemented tooltip traps keyboard users, announces garbage text to screen readers, or vanishes awkwardly when the mouse moves a single pixel off target.

In this technical guide, we will build a production-ready, accessible tooltip and popover component system in React using Floating UI for positioning and strict WAI-ARIA authoring practices for accessibility.


The Architecture of Accessible Popups

Before writing code, we need to understand the fundamental difference between a Tooltip and a Popover from an accessibility perspective:

  1. Tooltips:
    • Trigger: Hover or keyboard focus.
    • Content: Non-interactive, short strings of text.
    • ARIA Pattern: Uses aria-describedby pointing from the trigger to the tooltip element. Screen readers read the description immediately when focus lands on the trigger.
  2. Popovers (Dialog/Menu pattern):
    • Trigger: Click or explicit keyboard activation (Enter/Space).
    • Content: Interactive elements (buttons, links, inputs).
    • ARIA Pattern: Uses aria-expanded="true/false" and aria-controls. Focus typically moves into the popover content, and pressing Escape closes the popover and returns focus to the trigger.

By leveraging Floating UI, we get battle-tested positioning, flip behaviors, and shift modifiers out of the box, allowing us to focus entirely on state management, focus trapping, and keyboard ergonomics.


Setting Up the Base Hook

Let’s start by installing our dependencies:

bash
npm install @floating-ui/react

We can encapsulate our core logic into a reusable custom hook that handles open state, Floating UI hooks (useFloating, useDismiss, useRole, useInteractions), and accessibility wiring.

import {
  useFloating,
  useAutoUpdate,
  useDismiss,
  useRole,
  useInteractions,
  offset,
  flip,
  shift,
  Placement,
} from '@floating-ui/react';
import { useState } from 'react';

interface UsePopupOptions {
  initialOpen?: boolean;
  placement?: Placement;
  strategy?: 'tooltip' | 'popover';
  onOpenChange?: (open: boolean) => void;
}

export function usePopup({
  initialOpen = false,
  placement = 'top',
  strategy = 'tooltip',
  onOpenChange,
}: UsePopupOptions = {}) {
  const [isOpen, setIsOpen] = useState(initialOpen);

  const handleOpenChange = (open: boolean) => {
    setIsOpen(open);
    onOpenChange?.(open);
  };

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

  // Dismiss handles click outside and Escape key presses
  const dismiss = useDismiss(context, {
    escapeKey: true,
    referencePress: strategy === 'popover',
  });

  // Role informs screen readers whether this is a tooltip or a dialog/menu
  const role = useRole(context, {
    role: strategy === 'tooltip' ? 'tooltip' : 'dialog',
  });

  const interactions = useInteractions([dismiss, role]);

  return {
    isOpen,
    setIsOpen,
    refs,
    floatingStyles,
    context,
    getInteractionProps: interactions.getInteractionProps,
  };
}

Building the Accessible Tooltip Component

Tooltips must support hover and focus triggers. They should appear when the user hovers over the reference element or tabs into it via the keyboard, and they must disappear immediately when the user presses Escape or moves away.

Tooltip Implementation

import React, { useId, cloneElement, isValidElement } from 'react';
import {
  useHover,
  useFocus,
  useInteractions,
  safePolygon,
} from '@floating-ui/react';
import { usePopup } from './usePopup';

interface TooltipProps {
  label: string;
  children: React.ReactNode;
  placement?: 'top' | 'bottom' | 'left' | 'right';
}

export const Tooltip: React.FC<TooltipProps> = ({
  label,
  children,
  placement = 'top',
}) => {
  const { isOpen, setIsOpen, refs, floatingStyles, context } = usePopup({
    placement,
    strategy: 'tooltip',
  });

  const tooltipId = useId();

  // Tooltip specific interactions: hover with safe polygon and focus
  const hover = useHover(context, { move: false, handleClose: safePolygon() });
  const focus = useFocus(context);
  
  const { getReferenceProps, getFloatingProps } = useInteractions([
    hover,
    focus,
  ]);

  if (!isValidElement(children)) return null;

  return (
    <>
      {cloneElement(
        children,
        getReferenceProps({
          ref: refs.setReference,
          'aria-describedby': isOpen ? tooltipId : undefined,
          ...children.props,
        })
      )}
      {isOpen && (
        <div
          {...getFloatingProps({
            ref: refs.setFloating,
            style: floatingStyles,
            id: tooltipId,
            className: 'bg-gray-900 text-white text-xs px-2 py-1 rounded shadow-lg z-50 pointer-events-none',
          })}
        >
          {label}
        </div>
      )}
    </>
  );
};

Why safePolygon Matters

When a user moves their mouse toward a tooltip, a tiny gap between the trigger and the tooltip can cause the mouse to briefly leave the hover boundary, causing the tooltip to flicker out of existence. Floating UI’s safePolygon() generates an invisible polygon connecting the cursor to the floating element, keeping the tooltip open during natural mouse trajectories.


Building the Accessible Popover Component

Popovers contain interactive content. Unlike tooltips, popovers require explicit click or keyboard activation (Enter or Space), manage aria-expanded, and often benefit from managing focus inside the container.

Popover Implementation

import React, { useId, cloneElement, isValidElement } from 'react';
import {
  useClick,
  useRole,
  useDismiss,
  useInteractions,
  FloatingFocusManager,
} from '@floating-ui/react';
import { usePopup } from './usePopup';

interface PopoverProps {
  renderContent: (close: () => void) => React.ReactNode;
  children: React.ReactNode;
  placement?: 'bottom-start' | 'bottom-end' | 'top' | 'auto';
}

export const Popover: React.FC<PopoverProps> = ({
  renderContent,
  children,
  placement = 'bottom-start',
}) => {
  const { isOpen, setIsOpen, refs, floatingStyles, context } = usePopup({
    placement,
    strategy: 'popover',
  });

  const popoverId = useId();

  const click = useClick(context);
  const dismiss = useDismiss(context, { escapeKey: true });
  const role = useRole(context, { role: 'dialog' });

  const { getReferenceProps, getFloatingProps } = useInteractions([
    click,
    dismiss,
    role,
  ]);

  if (!isValidElement(children)) return null;

  return (
    <>
      {cloneElement(
        children,
        getReferenceProps({
          ref: refs.setReference,
          'aria-expanded': isOpen,
          'aria-controls': isOpen ? popoverId : undefined,
          ...children.props,
        })
      )}
      {isOpen && (
        <FloatingFocusManager context={context} modal={false}>
          <div
            {...getFloatingProps({
              ref: refs.setFloating,
              style: floatingStyles,
              id: popoverId,
              className: 'bg-white border border-gray-200 text-gray-800 p-4 rounded-xl shadow-xl z-50 outline-none',
            })}
          >
            {renderContent(() => setIsOpen(false))}
          </div>
        </FloatingFocusManager>
      )}
    </>
  );
};

The Role of FloatingFocusManager

When a popover opens, keyboard focus must move inside the popover container so screen reader users and keyboard-only users can interact with its contents. FloatingFocusManager automatically:

  • Shifts focus into the popover upon opening.
  • Traps Tab navigation inside the popover bounds.
  • Returns focus to the trigger button when the popover closes via Escape or outside click.

Keyboard Dismissal and Focus Management Best Practices

Handling accessibility isn’t just about adding aria attributes; it’s about adhering to predictable interaction patterns:

The Escape Key Rule: Pressing the Escape key must immediately close the active tooltip or popover. If a popover is open, focus must return precisely to the element that triggered it.

Our integration of useDismiss handles this seamlessly across browsers. Here is a quick breakdown of how our state handles keyboard navigation:

[User Focuses Trigger] 
       │
       ├─► Press Tab ──► Focus leaves, Tooltip closes
       │
       └─► Press Enter/Space (Popover) ──► Popover opens, Focus trapped inside
                                                 │
                                                 ├─► Press Escape ──► Popover closes, Focus returns to Trigger
                                                 └─► Click Outside ──► Popover closes

Putting It All Together in an Application

Here is how clean and declarative your component usage looks in practice:

export function DashboardHeader() {
  return (
    <header className="flex justify-between items-center p-4 border-b">
      <h1 className="text-xl font-bold">My Dashboard</h1>
      
      <div className="flex items-center gap-4">
        {/* Tooltip Example */}
        <Tooltip label="View system notifications">
          <button className="p-2 hover:bg-gray-100 rounded-full" aria-label="Notifications">
            🔔
          </button>
        </Tooltip>

        {/* Popover Example */}
        <Popover
          renderContent={(close) => (
            <div className="flex flex-col gap-2 w-48">
              <p className="font-medium text-sm">Signed in as user</p>
              <hr />
              <button 
                className="text-left text-sm text-red-600 hover:bg-red-50 p-1 rounded"
                onClick={() => {
                  alert('Logged out');
                  close();
                }}
              >
                Log out
              </button>
            </div>
          )}
        >
          <button className="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg">
            Account
          </button>
        </Popover>
      </div>
    </header>
  );
}

Conclusion

Building accessible floating elements doesn’t have to mean writing hundreds of lines of brittle event listeners and coordinate math. By combining Floating UI for positioning and collision detection with WAI-ARIA authoring primitives (aria-describedby, aria-expanded, focus management, and proper escape-key handling), you can deliver bulletproof user experiences that work flawlessly for mouse, touch, and keyboard/screen-reader users alike.

More posts