All posts
27 Sep 2026

Hover, Focus, Escape: Crafting Accessible Tooltips and Popovers in React

Learn how to build production-ready, highly accessible tooltips and popovers from scratch using React, TypeScript, and Floating UI.

Hover, Focus, Escape: Crafting Accessible Tooltips and Popovers in React

Tooltips and popovers are ubiquitous interface elements. They provide supplementary context, actions, or details without cluttering the primary layout. Yet, despite their widespread use, they are among the most frequently broken components in terms of accessibility and usability.

A poorly implemented tooltip traps keyboard users, remains invisible to screen readers, or vanishes frustratingly when a user attempts to move their mouse to interact with it.

In this guide, we will build a robust, accessible tooltip and popover primitive from scratch using React, TypeScript, and Floating UI. We will handle mouse versus keyboard focus management, dynamic ARIA attributes, touch device quirks, screen reader announcements, and the elusive escape-key handler.


The Anatomy of Floating Content: Tooltip vs. Popover

Before diving into code, let’s establish the distinction between our two targets:

  1. Tooltips: Provide short, non-interactive descriptive text for an element. They appear on hover or keyboard focus and disappear immediately when the trigger loses focus or the user moves the mouse away. They cannot contain interactive elements (like links or buttons).
  2. Popovers: Contain rich, interactive content (forms, menus, action lists). They typically open on click, trap focus optionally, and remain open until explicitly dismissed via an escape key, clicking outside, or interacting with an internal element.

Both components, however, share a core requirement: precise positioning relative to a reference element while managing overflow and viewport boundaries. This is where Floating UI shines.


Setting Up the Primitives

First, let’s install @floating-ui/react, which provides headless hooks specifically engineered for accessibility and positioning.

bash
npm install @floating-ui/react

We will build a unified compound component architecture that separates state management from presentation. Let’s create our types and context.

import React, { createContext, useContext, useState, useRef } from 'react';
import {
  useFloating,
  useInteractions,
  useHover,
  useFocus,
  useClick,
  useDismiss,
  useRole,
  offset,
  flip,
  shift,
  arrow,
  FloatingPortal,
  safePolygon,
} from '@floating-ui/react';

interface PopoverContextType {
  open: boolean;
  setOpen: (open: boolean) => void;
  refs: {
    setReference: (node: HTMLElement | null) => void;
    setFloating: (node: HTMLElement | null) => void;
  };
  floatingStyles: React.CSSProperties;
  context: any;
  labelId: string;
  descriptionId: string;
}

const PopoverContext = createContext<PopoverContextType | null>(null);

export const usePopover = () => {
  const context = useContext(PopoverContext);
  if (!context) throw new Error('Popover components must be wrapped in <Popover />');
  return context;
};

Crafting the Accessible Tooltip

Let’s construct a tooltip primitive. Tooltips require strict adherence to the WAI-ARIA pattern:

  • The trigger must have aria-describedby pointing to the tooltip ID.
  • The tooltip element must possess role="tooltip".
  • It must appear on both hover and keyboard focus.

Implementation

import React, { useId, useState } from 'react';
import {
  useFloating,
  useInteractions,
  useHover,
  useFocus,
  useDismiss,
  useRole,
  offset,
  flip,
  shift,
  safePolygon,
  FloatingPortal,
} from '@floating-ui/react';

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

export function Tooltip({ label, children, placement = 'top' }: TooltipProps) {
  const [isOpen, setIsOpen] = useState(false);
  const id = useId();

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

  const hover = useHover(context, { 
    move: false,
    restMs: 150,
    handleClose: safePolygon(), // Allows diagonal mouse movement to tooltip
  });
  const focus = useFocus(context);
  const dismiss = useDismiss(context);
  const role = useRole(context, { role: 'tooltip' });

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

  return (
    <>
      {React.cloneElement(children as React.ReactElement,
        getReferenceProps({
          ref: refs.setReference,
          'aria-describedby': isOpen ? id : undefined,
          ...((children as React.ReactElement).props || {}),
        })
      )}
      {isOpen && (
        <FloatingPortal>
          <div
            {...getFloatingProps({
              ref: refs.setFloating,
              style: {
                ...floatingStyles,
                background: '#1a1a1a',
                color: '#fff',
                padding: '4px 8px',
                borderRadius: '4px',
                fontSize: '12px',
                pointerEvents: 'none', // Crucial: prevents tooltip from stealing mouse focus
                zIndex: 9999,
              },
            })}
            id={id}
            role="tooltip"
          >
            {label}
          </div>
        </FloatingPortal>
      )}
    </>
  );
}

Key Accessibility & UX Details in the Tooltip

  1. pointerEvents: 'none': Because tooltips are non-interactive, disabling pointer events ensures the user’s mouse won’t accidentally get caught on the tooltip container, causing jittery hover states.
  2. safePolygon(): Floating UI provides safePolygon inside useHover. Without this, if a user moves their mouse from the trigger toward the floating element through the gap, the cursor momentarily exits both elements, triggering a close event. safePolygon creates an invisible trapezoidal boundary allowing smooth traversal.
  3. Conditional aria-describedby: We only inject aria-describedby when isOpen is true, keeping the accessibility tree clean when the tooltip is hidden.

Building a Robust Popover Component

Unlike tooltips, popovers contain interactive content. They require click triggers, focus trapping (optional depending on UX design, but essential for modals/dialogs), explicit keyboard navigation, and reliable escape-key handling.

Let’s build a modular Popover component using compound components (<Popover>, <PopoverTrigger>, <PopoverContent>).

import React, { useState, useId, createContext, useContext } from 'react';
import {
  useFloating,
  useClick,
  useDismiss,
  useRole,
  useInteractions,
  offset,
  flip,
  shift,
  FloatingPortal,
} from '@floating-ui/react';

interface PopoverContextType {
  open: boolean;
  setOpen: (open: boolean) => void;
  getReferenceProps: (userProps?: React.HTMLProps<Element>) => Record<string, any>;
  getFloatingProps: (userProps?: React.HTMLProps<Element>) => Record<string, any>;
  refs: any;
  floatingStyles: React.CSSProperties;
  labelId: string;
  descriptionId: string;
}

const PopoverContext = createContext<PopoverContextType | null>(null);

export function Popover({ children, placement = 'bottom-start' }: { children: React.ReactNode; placement?: any }) {
  const [open, setOpen] = useState(false);
  const labelId = useId();
  const descriptionId = useId();

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

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

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

  return (
    <PopoverContext.Provider
      value={{
        open,
        setOpen,
        getReferenceProps,
        getFloatingProps,
        refs,
        floatingStyles,
        labelId,
        descriptionId,
      }}
    >
      {children}
    </PopoverContext.Provider>
  );
}

Creating the Trigger and Content Sub-Components

export function PopoverTrigger({ children }: { children: React.ReactElement }) {
  const { refs, getReferenceProps, open } = useContext(PopoverContext)!;
  
  return React.cloneElement(children,
    getReferenceProps({
      ref: refs.setReference,
      'aria-expanded': open,
      ...children.props,
    })
  );
}

export function PopoverContent({ children }: { children: React.ReactNode }) {
  const { open, refs, floatingStyles, getFloatingProps, labelId } = useContext(PopoverContext)!;

  if (!open) return null;

  return (
    <FloatingPortal>
      <div
        {...getFloatingProps({
          ref: refs.setFloating,
          style: {
            ...floatingStyles,
            background: '#ffffff',
            color: '#000000',
            border: '1px solid #ccc',
            borderRadius: '8px',
            padding: '16px',
            boxShadow: '0 10px 25px rgba(0,0,0,0.1)',
            zIndex: 9999,
            minWidth: '200px',
          },
        })}
        aria-labelledby={labelId}
        tabIndex={-1}
      >
        {children}
      </div>
    </FloatingPortal>
  );
}

Masterclass: Handling Edge Cases

Writing the component is only half the battle. Let’s examine critical edge cases that separate amateur components from production-grade ones.

1. Escape Key Handling & Focus Restoration

When a user opens a popover and presses Escape, the popover must close and focus must immediately return to the trigger element that opened it. If focus drops back to document.body or an arbitrary point on the page, keyboard users lose their place.

Floating UI’s useDismiss hook handles escape key detection natively, and because it maintains a reference to the triggering element via context, focus restoration is handled seamlessly out of the box.

// Floating UI automatically restores focus to the reference element
const dismiss = useDismiss(context, {
  escapeKey: true,
  outsidePress: true,
});

2. Screen Reader Announcements for Dynamic Content

When tooltips appear, screen readers like VoiceOver or NVDA read the text bound via aria-describedby. However, popovers act as dialogs (role="dialog"). Screen readers will announce the role, the accessible name (aria-labelledby), and then read the first focusable element inside.

To ensure screen readers don’t stutter or skip content:

  • Always assign a clear aria-labelledby linking to a header inside the popover content.
  • Ensure the popover container has tabIndex={-1} so screen readers recognize it as a programmatic focus container upon opening.

3. Mobile Touch Considerations

Hover events do not exist on touchscreens. A mobile user tapping a tooltip trigger can experience confusing states where a tooltip flashes and instantly disappears.

To handle mobile gracefully, convert hover tooltips into tap-to-toggle popovers on touch devices, or disable hover triggers when window.matchMedia('(pointer: coarse)').matches is true. Floating UI allows conditional hook application:

const isTouch = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;

const hover = useHover(context, { enabled: !isTouch });
const click = useClick(context, { enabled: isTouch });

Putting It All Together in an App

Here is how clean your consumer code looks when using our primitive components:

export function App() {
  return (
    <div style={{ padding: '100px', display: 'flex', gap: '40px' }}>
      {/* Tooltip Example */}
      <Tooltip label="Copy code snippet to clipboard">
        <button>Copy</button>
      </Tooltip>

      {/* Popover Example */}
      <Popover>
        <PopoverTrigger>
          <button>Options ⚙️</button>
        </PopoverTrigger>
        <PopoverContent>
          <h3 id="popover-title" style={{ margin: '0 0 8px 0' }}>Settings</h3>
          <p style={{ margin: '0 0 12px 0', fontSize: '14px' }}>Manage your account preferences.</p>
          <button onClick={() => alert('Action clicked!')}>Profile Settings</button>
        </PopoverContent>
      </Popover>
    </div>
  );
}

Conclusion

Building accessible floating components requires meticulous attention to detail. By combining React, Floating UI, and strict adherence to WAI-ARIA authoring practices, you achieve:

  • Bulletproof positioning that never clips off screen edges.
  • Seamless keyboard navigation with automatic focus return on escape.
  • Inclusive screen reader support via dynamic aria-describedby and role="dialog" attributes.
  • Adaptive mobile behavior handling touch vs. pointer inputs correctly.

By treating accessibility as a foundational requirement rather than an afterthought, you create resilient user interfaces that work for everyone, everywhere.

More posts