All posts
11 Oct 2026

Tooltips and Popovers Done Right: Accessible Floating UI in React

A practical, code-heavy walkthrough on building accessible tooltips and popovers from scratch in React, covering ARIA attributes, keyboard navigation, and escape-key handling.

Tooltips and Popovers Done Right: Accessible Floating UI in React

Floating elements like tooltips and popovers are ubiquitous in modern web design. They provide context, reveal actions, and keep dense user interfaces clean. However, they are also among the most commonly broken components when it comes to web accessibility (a11y).

If you build a tooltip that only responds to mouseenter and mouseleave, you instantly alienate keyboard users and screen reader operators. If you fail to establish proper aria-describedby or aria-expanded relationships, assistive technologies will leave users completely in the dark.

In this article, we’ll build fully accessible, type-safe tooltip and popover components from scratch in React using TypeScript. We will tackle hover versus focus states, ARIA relationships, keyboard navigation, and focus management without relying on heavy third-party UI libraries.


The Core Challenges of Accessible Floating UI

Before diving into code, let’s establish what makes a floating element truly accessible:

  1. State Parity: Mouse hover must mirror keyboard focus. If a tooltip appears when a user hovers over an element, it must also appear when the user tabs into that element.
  2. Semantic Relationships: Screen readers need to know what describes an element (aria-describedby) or what a button controls (aria-expanded / aria-controls).
  3. Keyboard Interactivity: Tooltips should typically dismiss on Escape. Popovers often require focus to move inside the floating container when opened, allowing users to interact with contained links or buttons.
  4. Robust Positioning: While CSS positioning handles placement, our JavaScript must manage the state cleanly and react reliably to user input.

1. Building an Accessible Tooltip

A tooltip is a small, non-interactive popup that provides a description or helper text for an element. Because it is non-interactive, focus should remain on the trigger element, and screen readers should automatically read the tooltip content via aria-describedby when the trigger receives focus.

The TypeScript Tooltip Component

Let’s build a reusable Tooltip component using React hooks. We’ll manage open/closed state via both mouse and focus events.

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

interface TooltipProps {
  content: string;
  children: ReactElement;
  delay?: number;
}

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

  const showTooltip = () => {
    timeoutRef.current = setTimeout(() => {
      setIsVisible(true);
    }, delay);
  };

  const hideTooltip = () => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
    setIsVisible(false);
  };

  // Handle Escape key globally when tooltip is visible
  React.useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape' && isVisible) {
        hideTooltip();
      }
    };
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isVisible]);

  // Clone the trigger child to inject accessibility props and event handlers
  const trigger = cloneElement(children, {
    'aria-describedby': isVisible ? tooltipId : undefined,
    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();
    },
  });

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

Why This Works for Accessibility:

  • aria-describedby={isVisible ? tooltipId : undefined}: Dynamically attaches the tooltip’s ID to the trigger. When screen readers focus the trigger element, they read its label followed immediately by the tooltip content.
  • Focus & Hover Parity: onMouseEnter/onMouseLeave handle mouse users, while onFocus/onBlur ensure keyboard tab users experience the exact same behavior.
  • Escape Key Handling: Pressing Escape instantly dismisses the tooltip, satisfying standard keyboard patterns.

2. Building an Accessible Popover

Unlike tooltips, popovers contain interactive elements (like links, buttons, or form inputs). When a popover opens, keyboard focus should ideally transition inside the popover container so the user can interact with its contents. When closed, focus must return to the element that triggered it.

The TypeScript Popover Component

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

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

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

  const handleToggle = () => {
    setIsOpen((prev) => !prev);
  };

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

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

    if (isOpen) {
      document.addEventListener('keydown', handleKeyDown);
      document.addEventListener('mousedown', handleClickOutside);
    }

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

  // Focus trap / initial focus management inside popover
  useEffect(() => {
    if (isOpen && popoverRef.current) {
      const focusableElements = popoverRef.current.querySelectorAll<
        HTMLButtonElement | HTMLAnchorElement | HTMLInputElement
      >('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
      
      if (focusableElements.length > 0) {
        focusableElements[0].focus();
      } else {
        popoverRef.current.focus();
      }
    }
  }, [isOpen]);

  const trigger = cloneElement(children, {
    ref: (node: HTMLElement | null) => {
      triggerRef.current = node;
      // Handle ref forwarding if child already has a ref
      const { ref } = children;
      if (typeof ref === 'function') ref(node);
      else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;
    },
    'aria-expanded': isOpen,
    'aria-haspopup': 'dialog',
    'aria-controls': popoverId,
    onClick: (e: React.MouseEvent) => {
      children.props.onClick?.(e);
      handleToggle();
    },
  });

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

Key Accessibility Features of the Popover:

  • aria-haspopup="dialog" & aria-expanded={isOpen}: Communicates to screen reader users that activating the button opens a dialog overlay, and whether that overlay is currently expanded.
  • aria-controls={popoverId}: Establishes programmatic relationship between the trigger and the popover content container.
  • Focus Management: When the popover opens, focus shifts automatically to the first focusable element inside the popover. When closed via Escape, focus explicitly returns to the trigger button.
  • Click-Outside & Escape Dismissal: Ensures users can easily close the popover using standard interaction patterns.

3. Usage Example in React

Here is how clean and declarative your component tree looks using our custom Tooltip and Popover implementations:

import React from 'react';
import { Tooltip } from './Tooltip';
import { Popover } from './Popover';

export const App: React.FC = () => {
  return (
    <main style={{ padding: '40px', fontFamily: 'sans-serif' }}>
      <h1>Accessible Floating UI Demo</h1>
      
      <div style={{ marginBottom: '24px' }}>
        <Tooltip content="Saves your current changes to the cloud">
          <button style={{ padding: '8px 16px' }}>Save Progress</button>
        </Tooltip>
      </div>

      <div>
        <Popover
          content={
            <div>
              <h3>User Settings</h3>
              <p>Manage your account preferences.</p>
              <button onClick={() => alert('Settings clicked!')}>Open Settings</button>
            </div>
          }
        >
          <button style={{ padding: '8px 16px' }}>User Options</button>
        </Popover>
      </div>
    </main>
  );
};

Summary Checklist for Accessible Floating UI

Always test your floating components without a mouse. Navigate your app using only the Tab, Shift + Tab, Enter, and Escape keys, and verify screen reader output using VoiceOver (macOS) or NVDA (Windows).

  • Mouse & Keyboard Parity: Hover triggers match focus triggers.
  • ARIA Attributes: Use role="tooltip" with aria-describedby for tooltips; use role="dialog", aria-expanded, and aria-controls for popovers.
  • Keyboard Dismissal: Pressing Escape closes the floating element.
  • Focus Return: When a popover closes, focus returns to the originating trigger element.

More posts