All posts
2 Oct 2026

Hover and Hide: Building an Accessible Tooltip and Popover Component in React

A practical, code-heavy walkthrough showing how to combine Floating UI with proper ARIA attributes, hover/focus state management, and keyboard accessibility for React tooltips and popovers.

Hover and Hide: Building an Accessible Tooltip and Popover Component in React

Tooltips and popovers seem simple at first glance: a small box of content that appears when a user interacts with an element. Yet, building them correctly in a modern React application is deceptively complex.

A truly production-ready floating element must satisfy several non-trivial requirements:

  • Smart Positioning: It must automatically flip or shift when it approaches the edge of the viewport.
  • Accessibility (a11y): Screen readers need proper ARIA attributes (aria-describedby, aria-expanded, roles) to understand the relationship between the trigger and the content.
  • Keyboard Support: Users must be able to trigger the element via focus, and dismiss it instantly using the Escape key.
  • State Synchronization: Hover states, focus states, and click states must not conflict.

In this post, we will build a robust, accessible tooltip and popover component system in React and TypeScript using Floating UI, the industry-standard positioning engine.


Why Floating UI?

While you could position elements using absolute CSS and manual window resize listeners, doing so edge-case-proofs your UI poorly. Floating UI provides low-level positioning primitives that calculate coordinates dynamically, handling boundary collisions, scrolling containers, and arrow positioning with zero layout thrashing.

Let’s start by installing our dependencies:

bash
npm install @floating-ui/react

Building the Accessible Tooltip Component

A tooltip is intended for brief, informative hints. It appears on hover or focus, does not contain interactive elements (like buttons or links), and vanishes when the user moves away or presses Escape.

1. Setting Up the Tooltip Hook and State

We’ll use @floating-ui/react’s built-in hooks (useFloating, useInteractions, useHover, useFocus, useDismiss, useRole) to handle state management cleanly.

import React, { useState, cloneElement, isValidElement } from 'react';
import {
  useFloating,
  useInteractions,
  useHover,
  useFocus,
  useDismiss,
  useRole,
  offset,
  shift,
  flip,
  arrow,
  FloatingArrow,
  useTransitionStyles,
  safePolygon,
} from '@floating-ui/react';

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

export function Tooltip({ content, children, placement = 'top' }: TooltipProps) {
  const [isOpen, setIsOpen] = useState(false);
  const arrowRef = React.useRef(null);

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

  // Interaction hooks
  const hover = useHover(context, { move: false, handleClose: safePolygon() });
  const focus = useFocus(context);
  const dismiss = useDismiss(context);
  const role = useRole(context, { role: 'tooltip' });

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

  // Optional: Add smooth mount/unmount animations
  const { isMounted, styles } = useTransitionStyles(context, {
    initial: { opacity: 0, transform: 'scale(0.95)' },
    open: { opacity: 1, transform: 'scale(1)' },
    close: { opacity: 0, transform: 'scale(0.95)' },
    duration: 150,
  });

  // ... render logic below
}

2. Wiring Up ARIA Attributes and Rendering

Next, we merge the reference props onto our child element using cloneElement, ensuring we preserve existing refs and event handlers.

  return (
    <>
      {isValidElement(children) &&
        cloneElement(
          children,
          getReferenceProps({
            ref: refs.setReference,
            ...children.props,
          })
        )}

      {isMounted && (
        <div
          ref={refs.setFloating}
          style={{ ...floatingStyles, ...styles }}
          {...getFloatingProps()}
          className="z-50 px-3 py-1.5 text-xs font-medium text-white bg-slate-900 rounded shadow-lg pointer-events-none"
        >
          {content}
          <FloatingArrow
            ref={arrowRef}
            context={context}
            className="fill-slate-900"
          />
        </div>
      )}
    </>
  );
}

Accessibility Note: Notice the role: 'tooltip' configuration passed to useRole. Floating UI automatically generates the necessary aria-describedby IDs connecting the trigger element to the tooltip content node, making it fully screen-reader compliant.


Building the Popover Component

Unlike tooltips, popovers are rich containers that can hold interactive elements like form inputs, checkboxes, and action buttons. Popovers are typically triggered by clicks, managed via aria-expanded, and must trap focus or dismiss predictably.

1. Popover Architecture

Let’s implement a compound-component style popover system or a flexible single-component popover with internal state management.

import React, { useState, useRef } from 'react';
import {
  useFloating,
  useClick,
  useDismiss,
  useRole,
  useInteractions,
  offset,
  flip,
  shift,
  arrow,
  FloatingArrow,
  FloatingFocusManager,
  useTransitionStyles,
} from '@floating-ui/react';

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

export function Popover({ renderContent, children, placement = 'bottom-start' }: PopoverProps) {
  const [isOpen, setIsOpen] = useState(false);
  const arrowRef = useRef(null);

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

  const click = useClick(context);
  const dismiss = useDismiss(context, {
    // Pressing Escape or clicking outside dismisses the popover
    escapeKey: true,
  });
  const role = useRole(context, { role: 'dialog' });

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

  const { isMounted, styles } = useTransitionStyles(context, {
    initial: { opacity: 0, transform: 'translateY(-8px)' },
    open: { opacity: 1, transform: 'translateY(0)' },
    close: { opacity: 0, transform: 'translateY(-8px)' },
  });

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

  return (
    <>
      {React.cloneElement(
        children,
        getReferenceProps({
          ref: refs.setReference,
          'aria-expanded': isOpen,
          ...children.props,
        })
      )}

      {isMounted && (
        <FloatingFocusManager context={context} modal={false}>
          <div
            ref={refs.setFloating}
            style={{ ...floatingStyles, ...styles }}
            {...getFloatingProps()}
            className="z-50 w-72 p-4 bg-white border border-slate-200 rounded-xl shadow-xl text-slate-800"
          >
            {renderContent(closePopover)}
            <FloatingArrow
              ref={arrowRef}
              context={context}
              className="fill-white stroke-slate-200"
            />
          </div>
        </FloatingFocusManager>
      )}
    </>
  );
}

2. Handling Focus Management Correctly

Notice the inclusion of <FloatingFocusManager modal={false}>. This is critical for popovers containing interactive content:

  • When the popover opens, keyboard focus shifts inside the floating element so screen reader users and keyboard-only navigators immediately access the content.
  • When the popover closes, focus returns naturally to the trigger element, preventing the user’s focus context from resetting to the top of the document.

Putting It Together: Usage Example

Here is how clean and declarative your component usage looks in a real application:

export function App() {
  return (
    <div className="p-12 flex gap-8 items-center">
      {/* Tooltip Example */}
      <Tooltip content="Copy code snippet to clipboard">
        <button className="px-4 py-2 bg-slate-100 rounded-md font-medium text-sm hover:bg-slate-200 transition">
          Hover me
        </button>
      </Tooltip>

      {/* Popover Example */}
      <Popover
        placement="bottom-start"
        renderContent={(close) => (
          <div className="space-y-3">
            <h4 className="font-semibold text-sm text-slate-900">Filter Options</h4>
            <p className="text-xs text-slate-500">Select your preferred view settings below.</p>
            <div className="flex justify-end gap-2 pt-2">
              <button 
                onClick={close}
                className="px-3 py-1.5 text-xs bg-slate-900 text-white rounded font-medium"
              >
                Apply
              </button>
            </div>
          </div>
        )}
      >
        <button className="px-4 py-2 bg-blue-600 text-white rounded-md font-medium text-sm hover:bg-blue-700 transition">
          Open Popover
        </button>
      </Popover>
    </div>
  );
}

Summary Checklist for Accessible Floating Elements

When building or auditing custom floating UI components, ensure you verify:

  1. Semantic Roles: Use role="tooltip" for non-interactive helper text, and role="dialog" or role="menu" for interactive popovers.
  2. Keyboard Dismissal: The Escape key must close the floating element and return focus appropriately.
  3. Focus Management: Interactive popovers must shift focus inside upon opening and return focus to the trigger upon closing using tools like Floating UI’s FloatingFocusManager.
  4. ARIA States: Trigger buttons must correctly expose aria-expanded (for popovers/menus) or rely on programmatic aria-describedby linkage (for tooltips).

By leveraging Floating UI alongside React state primitives, you get robust cross-browser positioning out of the box while maintaining strict adherence to WAI-ARIA authoring practices.

More posts