All posts
4 Oct 2026

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

Learn how to build production-ready, fully accessible tooltip and popover components in React featuring mouse and keyboard parity, dynamic boundary detection, and robust focus management.

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

Tooltips and popovers are ubiquitous patterns in modern web applications. They provide supplementary context or interactive overlays without cluttering the primary UI. However, they are also among the most frequently mishandled components when it comes to web accessibility (a11y) and edge-case positioning.

Too often, we see custom tooltips built as simple div elements toggled on onMouseEnter. This approach immediately alienates keyboard users, screen reader users, and anyone interacting with your app on a mobile device or zoomed viewport. Furthermore, when these elements attempt to render near the edge of the screen, they often get clipped or force unwanted horizontal scrollbars.

In this guide, we will build a robust, accessible tooltip and popover system from scratch using React and TypeScript. We will tackle mouse and keyboard parity, aria-* relationships, collision detection, and clean dismissal via the Escape key.


Understanding the Core Requirements

Before writing code, let’s establish what makes a floating UI component truly accessible and production-ready:

  1. Mouse & Keyboard Parity: If a tooltip appears when hovering with a mouse, it must also appear when the trigger element receives keyboard focus.
  2. Semantic Relationships: Tooltips must be linked to their triggers using aria-describedby (for tooltips) or aria-labelledby and aria-expanded (for popovers).
  3. Screen Reader Announcements: Assistive technologies should read the tooltip content smoothly without stuttering or double-announcing.
  4. Dismissal via Escape: Pressing the Escape key must immediately dismiss the floating element and return focus appropriately.
  5. Dynamic Positioning & Boundary Collision: The element must never overflow the viewport boundaries.

The Architecture: Shared Foundation with Floating UI

While tooltips and popovers serve different UX purposes—tooltips are brief and non-interactive; popovers can contain forms, links, and buttons—they share the same underlying floating mechanics. We can leverage @floating-ui/react for battle-tested positioning math while writing our own accessible behavior layers.

First, install the necessary dependencies:

bash
npm install @floating-ui/react

Building the Accessible Tooltip

Let’s build the Tooltip component first. A tooltip should appear on hover or focus, disappear on mouse leave or blur, and respect the Escape key.

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

interface TooltipProps {
  label: string;
  children: React.ReactNode;
}

export const Tooltip: React.FC<TooltipProps> = ({ label, children }) => {
  const [isOpen, setIsOpen] = useState(false);
  const tooltipId = useId();

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

  // Floating UI interaction hooks
  const hover = useHover(context, { move: false, delay: { open: 200, close: 0 } });
  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 ? tooltipId : undefined,
        })
      )}
      {isOpen && (
        <FloatingPortal>
          <div
            {...getFloatingProps({
              ref: refs.setFloating,
              id: tooltipId,
              style: floatingStyles,
              className: 'tooltip-content',
            })}
          >
            {label}
          </div>
        </FloatingPortal>
      )}
    </>
  );
};

Why this code works:

  • useId(): Generates a stable, unique DOM ID that prevents collisions across multiple tooltips on the same page.
  • aria-describedby: Dynamically binds the trigger to the tooltip element only when isOpen is true, ensuring screen readers announce the description cleanly.
  • useHover and useFocus: Guarantees that keyboard users tabbing onto the element experience the exact same reveal behavior as mouse users.
  • FloatingPortal: Renders the tooltip at the root of the DOM (document.body), preventing clipping issues caused by parent containers with overflow: hidden or stacking context traps (z-index).

—n

Scaling to Complex Interactions: The Popover Component

Unlike tooltips, popovers are interactive. They contain buttons, inputs, or links. Clicking inside a popover should not close it, but clicking outside or pressing Escape must.

Let’s construct a flexible Popover component:

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

interface PopoverProps {
  renderContent: (close: () => void) => React.ReactNode;
  children: React.ReactNode;
  placement?: 'bottom' | 'top' | 'left' | 'right';
}

export const Popover: React.FC<PopoverProps> = ({
  renderContent,
  children,
  placement = 'bottom',
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const popoverId = useId();

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

  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,
  ]);

  const handleClose = () => setIsOpen(false);

  return (
    <>
      {React.cloneElement(children as React.ReactElement,
        getReferenceProps({
          ref: refs.setReference,
          'aria-haspopup': 'dialog',
          'aria-expanded': isOpen,
          'aria-controls': popoverId,
        })
      )}
      {isOpen && (
        <FloatingPortal>
          <div
            {...getFloatingProps({
              ref: refs.setFloating,
              id: popoverId,
              style: floatingStyles,
              className: 'popover-content',
            })}
          >
            {renderContent(handleClose)}
          </div>
        </FloatingPortal>
      )}
    </>
  );
};

Key Differences in Popover Accessibility:

  • aria-expanded and aria-controls: Tells screen reader users whether the trigger is expanded and which element controls the dialog content.
  • Role dialog: Signals to assistive tech that this region is an interactive dialog window rather than static descriptive text.
  • Render Prop Pattern (renderContent): Passes a close callback down to the children so internal actions (like a “Cancel” or “Submit” button) can programmatically dismiss the popover.

—n

Handling Dynamic Positioning and Boundary Collisions

Floating elements frequently break layouts when placed near viewport edges. Floating UI solves this via three key middleware functions configured in our hooks:

  1. offset(8): Leaves a clean 8-pixel gap between the reference element and the floating panel.
  2. flip(): Automatically flips the element to the opposite side (e.g., from bottom to top) if there is insufficient vertical space in the viewport.
  3. shift({ padding: 8 }): Slidably adjusts the element horizontally or vertically along its axis to prevent it from bleeding past the left or right edges of the screen.
middleware: [
  offset(8),
  flip({
    fallbackAxisSideDirection: 'start',
  }),
  shift({ padding: 12 }),
]

Pro Tip: Always pass whileElementsMounted: autoUpdate to your useFloating hook. This ensures that if the user scrolls the page or resizes the browser window while the tooltip or popover is open, the floating element repositions itself in real-time.

—n

Focus Management and Keyboard Dismissal

For popovers containing interactive elements, managing focus flow is critical. When a popover opens, users expect focus to move into the container. When it closes via Escape or outside click, focus must return to the reference trigger element to maintain the user’s natural tab sequence.

Fortunately, @floating-ui/react handles returning focus to the reference element out-of-the-box when useDismiss is triggered via the Escape key or outside click.

Let’s verify our keyboard event flow:

  1. User tabs to the trigger and presses Enter or Space (handled by useClick).
  2. Popover opens; focus moves naturally into the popover content.
  3. User presses Escape.
  4. Popover unmounts, and browser focus snaps directly back to the trigger button.

—n

Styling for Polish

To ensure your floating elements look as good as they function, pair your components with clean CSS. Here is a baseline stylesheet utilizing CSS variables and smooth transitions:

.tooltip-content {
  background-color: #1e293b;
  color: #f8fafc;
  padding: 0.375rem 0.75rem;
  font-size: 0.875rem;
  border-radius: 0.375rem;
  box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
  pointer-events: none;
  z-index: 50;
  max-width: 240px;
}

.popover-content {
  background-color: #ffffff;
  color: #0f172a;
  padding: 1rem;
  border-radius: 0.5rem;
  border: 1px solid #e2e8f0;
  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
  z-index: 50;
  width: 300px;
}

—n

Conclusion

Building accessible UI components requires looking beyond the happy path of mouse interaction. By combining React, TypeScript, and Floating UI, we’ve created robust Tooltip and Popover components that:

  • Maintain strict mouse and keyboard parity.
  • Implement correct ARIA roles and relationships (aria-describedby, aria-expanded, role).
  • Gracefully handle viewport boundary collisions without horizontal overflow.
  • Respect user intent by dismissing cleanly on Escape and returning focus where it belongs.

Adopting these patterns in your design system ensures your web applications remain usable, delightful, and accessible to every user.

More posts