All posts
23 Sep 2026

Hover and Hide: Crafting Accessible Tooltips and Popovers in React

Learn how to build a production-ready, highly accessible tooltip and popover system in React and TypeScript using Floating UI, complete with ARIA attributes, focus management, and keyboard dismissal.

Hover and Hide: Crafting Accessible Tooltips and Popovers in React

Tooltips and popovers are ubiquitous UI patterns. They offer contextual hints or hide rich, interactive content behind a simple trigger. However, despite their commonality, they are notoriously difficult to implement correctly.

Building a truly robust floating element system requires solving several hard problems:

  • Positioning: Ensuring the element never clips off the edge of the viewport.
  • Accessibility: Connecting screen readers to descriptions and managing focus correctly.
  • Interaction Design: Handling hover intent, focus rings, and touch devices.
  • Keyboard Navigation: Allowing users to dismiss popovers instantly using the Escape key.

In this guide, we will build a robust, accessible tooltip and popover system in React and TypeScript using Floating UI—the industry standard library for positioning floating elements—alongside native React hooks for keyboard and focus management.


Why Floating UI?

In the past, developers relied on CSS absolute positioning combined with complex calculation scripts to place tooltips. This approach inevitably fails when users scroll, resize the browser, or encounter cramped viewports.

Floating UI provides low-level positioning primitives (@floating-ui/react) that calculate optimal coordinates, handle flip behaviors when hitting boundaries, shift elements to stay on-screen, and render CSS arrows pointing precisely at the trigger element.

Let’s start by installing the required packages:

bash
npm install @floating-ui/react

—v

The Anatomy of an Accessible Tooltip

Before writing code, let’s review the WAI-ARIA Authoring Practices Guide (APG) for tooltips and popovers. A proper tooltip requires:

  1. aria-describedby: The trigger element must reference the ID of the tooltip container so screen readers announce the tooltip’s contents when the trigger receives focus or hover.
  2. Role: The tooltip element should carry role="tooltip".
  3. Focus vs. Hover: Tooltips should appear on both mouse hover and keyboard focus, and disappear when the user moves the mouse away or presses Escape.

Let’s build a unified Tooltip component that encapsulates these requirements.

Building the Tooltip Component

import React, { useState } from 'react';
import {
  useFloating,
  useId,
  useHover,
  useFocus,
  useDismiss,
  useRole,
  useInteractions,
  FloatingPortal,
  offset,
  shift,
  flip,
  arrow,
} 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 arrowRef = React.useRef(null);

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

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

  // Merge interactions into reference and floating props
  const { getReferenceProps, getFloatingProps } = useInteractions([
    hover,
    focus,
    dismiss,
    role,
  ]);

  const headingId = useId();

  return (
    <>
      {/* Clone or wrap trigger element with reference props */}
      {React.cloneElement(children as React.ReactElement,
        getReferenceProps({
          ref: refs.setReference,
          ...((children as React.ReactElement).props || {}),
        })
      )}

      {isOpen && (
        <FloatingPortal>
          <div
            ref={refs.setFloating}
            style={floatingStyles}
            {...getFloatingProps()}
            id={headingId}
            className="z-50 px-3 py-1.5 text-xs font-medium text-white bg-slate-900 rounded-md shadow-sm"
          >
            {label}
            <div
              ref={arrowRef}
              style={{
                position: 'absolute',
                left: middlewareData.arrow?.x != null ? `${middlewareData.arrow.x}px` : '',
                top: middlewareData.arrow?.y != null ? `${middlewareData.arrow.y}px` : '',
              }}
              className="w-2 h-2 bg-slate-900 rotate-45"
            />
          </div>
        </FloatingPortal>
      )}
    </>
  );
};

Key Takeaways from the Tooltip Implementation:

  • FloatingPortal: Renders the tooltip at the end of the DOM (usually inside document.body). This guarantees that your floating elements will never be clipped by parent containers with overflow: hidden or stacking context issues (z-index).
  • useHover and useFocus: Floating UI automatically synchronizes hover and focus states, ensuring keyboard-only users get the exact same visual cues as mouse users.
  • useDismiss: Automatically hooks into the Escape key to close the floating element and returns focus gracefully.

Leveling Up: Building an Interactive Popover

While tooltips contain plain static text and should not receive keyboard focus inside them, popovers often contain interactive elements like buttons, links, or form fields.

When a popover opens, keyboard focus must move inside the popover container so users can navigate its contents cleanly.

The Popover Component

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

interface PopoverProps {
  trigger: React.ReactNode;
  content: (close: () => void) => React.ReactNode;
}

export const Popover: React.FC<PopoverProps> = ({ trigger, content }) => {
  const [isOpen, setIsOpen] = useState(false);

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

  const click = useClick(context);
  const dismiss = useDismiss(context, {
    // Esc key will dismiss the popover and return focus to the trigger
    escapeKey: true,
  });
  const role = useRole(context, { role: 'dialog' });

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

  const triggerRef = useMergeRefs([
    refs.setReference,
    (trigger as any).ref,
  ]);

  return (
    <>
      {React.cloneElement(trigger as React.ReactElement, {
        ...getReferenceProps({
          ref: triggerRef,
          ...((trigger as React.ReactElement).props || {}),
        }),
      })}

      {isOpen && (
        <FloatingPortal>
          <div
            ref={refs.setFloating}
            style={floatingStyles}
            {...getFloatingProps()}
            className="z-50 w-72 p-4 bg-white border border-slate-200 rounded-lg shadow-xl"
          >
            {content(() => setIsOpen(false))}
          </div>
        </FloatingPortal>
      )}
    </>
  );
};

Solving Edge Cases: Focus Management and Escape Key Dismissal

When dealing with floating elements, accessibility bugs usually stem from improper focus management. Let’s look at how Floating UI solves these challenges out-of-the-box.

1. The Escape Key Contract

Users expect the Escape key to act as the universal “get me out of here” command. The useDismiss hook listens globally for keydown events on the window while the floating element is active:

const dismiss = useDismiss(context, {
  escapeKey: true,
  outsidePress: true, // Dismisses when clicking outside the popover
});

When Escape is pressed:

  1. The floating element state (isOpen) flips to false.
  2. Focus automatically returns to the element stored in refs.reference (the button that triggered the popover).

2. Preventing Scrolling and Clipping

By utilizing Floating UI’s shift() and flip() middleware, your popover will automatically adjust its placement if it encounters a browser viewport edge:

middleware: [
  offset(10),
  flip({ fallbackPlacements: ['top-start', 'bottom-end'] }),
  shift({ padding: 16 }),
]
  • flip(): If a popover opens downwards but overflows the bottom of the screen, Floating UI flips it to open upwards.
  • shift(): If a popover overflows on the left or right, it nudges the element inward, maintaining a guaranteed 16px padding from the viewport border.

Putting It All Together in an App

Here is how clean and declarative your component usage looks when consumed in a real-world React dashboard:

export function Dashboard() {
  return (
    <div className="p-8 flex gap-4 items-center">
      {/* Simple Tooltip Example */}
      <Tooltip label="Delete item permanently">
        <button className="px-4 py-2 bg-red-50 text-red-600 rounded-md hover:bg-red-100">
          Delete
        </button>
      </Tooltip>

      {/* Rich Popover Example */}
      <Popover
        trigger={
          <button className="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">
            Options
          </button>
        }
        content={(close) => (
          <div className="space-y-3">
            <h4 className="font-medium text-slate-900">Preferences</h4>
            <p className="text-sm text-slate-600">
              Manage your notification settings for this workspace.
            </p>
            <div className="flex justify-end gap-2 pt-2">
              <button
                onClick={close}
                className="px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100 rounded"
              >
                Cancel
              </button>
              <button
                onClick={() => {
                  /* Save logic */
                  close();
                }}
                className="px-3 py-1.5 text-xs bg-indigo-600 text-white rounded"
              >
                Save Changes
              </button>
            </div>
          </div>
        )}
      />
    </div>
  );
}

Conclusion

Creating accessible floating components doesn’t have to mean writing hundreds of lines of brittle math and event-listener code. By combining React, TypeScript, and Floating UI, we get robust viewport calculations, correct ARIA role management, seamless keyboard focus restoration, and bulletproof Escape key dismissal out of the box.

Always remember your checklist when building floating UI elements:

  • ✅ Use FloatingPortal to dodge stacking context and clipping traps.
  • ✅ Hook into useDismiss to support standard keyboard behaviors (Escape).
  • ✅ Ensure interactive popovers trap or transition focus appropriately, while static tooltips rely on aria-describedby.

Happy coding, and build accessible apps!

More posts