All posts
13 Oct 2026

Hover and Focus: Building an Accessible Tooltip and Popover in React

A practical, step-by-step guide to building lightweight, accessible tooltips and popovers in React with full keyboard navigation, ARIA wiring, and zero third-party UI dependencies.

Hover and Focus: Building an Accessible Tooltip and Popover in React

Tooltips and popovers are ubiquitous UI patterns. We use tooltips to provide brief, supplementary descriptions for icons or truncated text, and popovers to present richer content—like menus, user profiles, or quick forms—on demand.

However, they are also among the most frequently mishandled components in web development. A naive implementation using a simple onMouseEnter / onMouseLeave state hook completely alienates keyboard-only users, screen reader operators, and mobile touch users.

In this guide, we will build fully accessible, lightweight Tooltip and Popover components in React using TypeScript. We will cover correct ARIA attributes, robust keyboard navigation, focus management, and graceful handling of touch devices—all without bloating our bundle with heavy third-party UI libraries.


The Core Requirements of Accessible Overlays

Before writing code, let’s establish what makes an overlay component accessible:

  1. Mouse and Keyboard Parity: If a tooltip or popover opens on hover, it must also open on keyboard focus.
  2. Dismissal via Escape: Pressing the Escape key must immediately close the overlay and return focus appropriately.
  3. Semantic ARIA Wiring:
    • Tooltips use role="tooltip" linked via aria-describedby.
    • Popovers use aria-expanded and manage focus movement.
  4. Touch Device Resilience: Hover events do not map cleanly to touch screens; tapping should toggle state predictably.

1. Building the Accessible Tooltip Component

A tooltip provides contextual text for an element. It should appear on hover or focus, and disappear when the mouse leaves or the user presses Escape or moves focus away.

The TypeScript Implementation

tsx
import React, { useState, useRef, useId, cloneElement, ReactElement, useEffect } from 'react';

interface TooltipProps {
  content: string;
  children: ReactElement;
}

export const Tooltip: React.FC<TooltipProps> = ({ content, children }) => {
  const [isVisible, setIsVisible] = useState(false);
  const tooltipId = useId();
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);

  const showTooltip = () => {
    if (timeoutRef.current) clearTimeout(timeoutRef.current);
    setIsVisible(true);
  };

  const hideTooltip = () => {
    timeoutRef.current = setTimeout(() => {
      setIsVisible(false);
    }, 100);
  };

  // Handle Escape key dismissal
  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape' && isVisible) {
        setIsVisible(false);
      }
    };
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isVisible]);

  // Clone the trigger child to inject accessibility props
  const trigger = cloneElement(children, {
    onMouseEnter: (e: React.MouseEvent) => {
      children.props.onMouseEnter?.(e);
      showTooltip();
    },
    onMouseLeave: (e: React.MouseEvent) => {
      children.props.onMouseLeave?.(e);
      hideTooltip();
    },
    onFocus: (e: React.FocusEvent) => {
      children.props.onFocus?.(e);
      showTooltip();
    },
    onBlur: (e: React.FocusEvent) => {
      children.props.onBlur?.(e);
      hideTooltip();
    },
    'aria-describedby': isVisible ? tooltipId : undefined,
  });

  return (
    <span className="tooltip-wrapper" style={{ position: 'relative', display: 'inline-block' }}>
      {trigger}
      {isVisible && (
        <div
          id={tooltipId}
          role="tooltip"
          className="tooltip-bubble"
          style={{
            position: 'absolute',
            bottom: '100%',
            left: '50%',
            transform: 'translateX(-50%)',
            marginBottom: '6px',
            padding: '4px 8px',
            backgroundColor: '#1a1a1a',
            color: '#fff',
            borderRadius: '4px',
            fontSize: '0.75rem',
            whiteSpace: 'nowrap',
            pointerEvents: 'none',
            zIndex: 1000,
          }}
        >
          {content}
        </div>
      )}
    </span>
  );
};

Why this works for accessibility:

  • aria-describedby: When isVisible is true, the trigger element receives an aria-describedby attribute pointing to the tooltip’s unique ID (useId()). Screen readers will automatically announce the tooltip text when the element receives focus.
  • Focus & Hover parity: By wiring both onMouseEnter/onMouseLeave and onFocus/onBlur, keyboard users tabbing through the page get the exact same visual cue and screen reader announcement as mouse users.
  • Escape Key handling: Global keyboard listeners ensure that hitting Escape cleanly dismisses the tooltip without requiring the user to navigate away.

2. Building the Accessible Popover Component

While tooltips are read-only and transient, popovers contain interactive elements (like buttons, links, or inputs). This introduces a critical accessibility requirement: focus management.

When a popover opens, focus should ideally move inside the popover. When it closes, focus must return to the element that triggered it.

The Popover Implementation

import React, { useState, useRef, useId, useEffect, ReactElement, cloneElement } from 'react';

interface PopoverProps {
  content: React.ReactNode;
  children: ReactElement;
}

export const Popover: React.FC<PopoverProps> = ({ content, children }) => {
  const [isOpen, setIsOpen] = useState(false);
  const popoverId = useId();
  const triggerRef = useRef<HTMLElement | null>(null);
  const popoverRef = useRef<HTMLDivElement | null>(null);

  const togglePopover = () => setIsOpen((prev) => !prev);

  // Handle Escape key and outside clicks
  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape' && isOpen) {
        setIsOpen(false);
        triggerRef.current?.focus();
      }
    };

    const handleClickOutside = (event: MouseEvent) => {
      if (
        isOpen &&
        popoverRef.current &&
        !popoverRef.current.contains(event.target as Node) &&
        triggerRef.current &&
        !triggerRef.current.contains(event.target as Node)
      );
    };

    document.addEventListener('keydown', handleKeyDown);
    document.addEventListener('mousedown', handleClickOutside);
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, [isOpen]);

  // Focus management when opening/closing
  useEffect(() => {
    if (isOpen) {
      // Optionally focus the first focusable element inside the popover
      const focusableElement = popoverRef.current?.querySelector<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      focusableElement?.focus();
    }
  }, [isOpen]);

  const trigger = cloneElement(children, {
    ref: (node: HTMLElement) => {
      triggerRef.current = node;
      // Preserve existing refs if passed
      const { ref } = children;
      if (typeof ref === 'function') ref(node);
      else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;
    },
    onClick: (e: React.MouseEvent) => {
      children.props.onClick?.(e);
      togglePopover();
    },
    'aria-haspopup': 'dialog',
    'aria-expanded': isOpen,
    'aria-controls': popoverId,
  });

  return (
    <div className="popover-container" style={{ position: 'relative', display: 'inline-block' }}>
      {trigger}
      {isOpen && (
        <div
          ref={popoverRef}
          id={popoverId}
          role="dialog"
          tabIndex={-1}
          style={{
            position: 'absolute',
            top: '100%',
            left: '0',
            marginTop: '8px',
            padding: '16px',
            backgroundColor: '#ffffff',
            color: '#000000',
            border: '1px solid #ccc',
            borderRadius: '6px',
            boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
            zIndex: 1000,
          }}
        >
          {content}
        </div>
      )}
    </div>
  );
};

3. Key ARIA Attributes Explained

Getting the ARIA contract right ensures screen readers interpret the relationship between triggers and overlays correctly:

  • aria-haspopup="dialog": Informs assistive tech that activating this element will spawn a popover dialog container.
  • aria-expanded={isOpen}: Dynamically communicates whether the popover is currently expanded (true) or collapsed (false). Screen readers announce this state change immediately upon activation.
  • aria-controls={popoverId}: Binds the trigger directly to the DOM ID of the popover content container.
  • role="dialog": Signals to screen reader users that the popover is a distinct modal or popup section containing interactive widgets.

4. Handling Touch Devices and Edge Cases

Mobile and touch screen interactions present unique challenges because touch devices lack a true “hover” state.

  1. Tap to Toggle: For tooltips on mobile, users often tap the element to trigger the tooltip, then tap elsewhere to dismiss it. Ensuring our click/blur handlers don’t instantly trap or misfire on touch screens is crucial.
  2. Viewport Boundary Detection: In production applications, hardcoding top: 100% or bottom: 100% will cause overlays to clip off screen edges. While calculating exact bounding boxes manually can be complex, modern CSS offers anchor-name and position-anchor, and lightweight positioning libraries like @floating-ui/react can be layered on top of these accessible primitives if your design requires automated viewport flipping.

Conclusion

Building accessible components doesn’t require massive UI framework dependencies. By understanding core browser events, semantic ARIA attributes, and robust focus management techniques in React, you can create delightful, fully inclusive tooltips and popovers from scratch.

Summary Checklist for Your Overlays:

  • Are triggers accessible via keyboard (Tab and Enter/Space)?
  • Do tooltips use role="tooltip" and aria-describedby?
  • Do popovers use aria-expanded, aria-haspopup, and aria-controls?
  • Does pressing Escape dismiss the overlay and restore focus to the trigger?
  • Are focus rings visible and clearly styled for keyboard navigators?

More posts