All posts
8 Oct 2026

Hover, Focus, and Announce: Building an Accessible Tooltip in React

Learn how to build a production-ready, highly accessible tooltip component in React using TypeScript, @floating-ui, and robust ARIA attributes.

Hover, Focus, and Announce: Building an Accessible Tooltip in React

Tooltips are deceptively simple UI elements. At first glance, they appear to be nothing more than a small absolute-positioned box that appears when a user hovers over an element. However, building a tooltip that satisfies modern web accessibility standards (WCAG), handles complex layouts without clipping, supports keyboard navigation, and announces changes correctly to screen readers is a surprisingly nuanced engineering challenge.

In this guide, we will build a production-ready tooltip component in React using TypeScript and @floating-ui/react. By the end, you will have a resilient component that handles hover, focus states, Escape-key dismissal, viewport boundary flipping, and correct aria-describedby wiring.


The Anatomy of an Accessible Tooltip

Before writing code, let’s establish the requirements for a truly accessible tooltip:

  1. Semantic Association: Screen readers must connect the trigger element to the tooltip content via aria-describedby.
  2. State Parity: The tooltip must appear both when the trigger is hovered with a mouse and when it receives keyboard focus.
  3. Dismissal: Pressing the Escape key must immediately close the tooltip.
  4. Smart Positioning: It should never clip off-screen, automatically flipping its placement if it hits a viewport boundary.
  5. No Focus Traps: Unlike a dialog or modal, a tooltip shouldn’t trap keyboard focus; the user should be able to tab right past it.

Setting Up the Dependencies

We’ll use @floating-ui/react for positioning calculations and interaction hooks, along with lucide-react for any icon triggers if needed.

bash
npm install @floating-ui/react
npm install -D typescript @types/react

Building the Tooltip Component

Let’s implement our tooltip using a compound component or a flexible props-based approach. We will encapsulate the floating-ui logic inside a custom hook or directly within our component wrapper.

Step 1: Types and State Management

Create a file named Tooltip.tsx:

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

interface TooltipProps {
  label: string;
  children: ReactElement;
  placement?: Placement;
}

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

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

  // Interaction hooks
  const hover = useHover(context, { 
    move: false,
    // safePolygon allows users to move the mouse diagonally into the tooltip
    handleClose: safePolygon(),
  });
  const focus = useFocus(context);
  const dismiss = useDismiss(context);
  const role = useRole(context, { role: *'tooltip'* });

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

  // Generate a stable unique ID for aria-describedby
  const headingId = useId();

  return (
    <>
      {cloneElement(
        children,
        getReferenceProps({
          ref: refs.setReference,
          ...children.props,
        })
      )}
      {isOpen && (
        <div
          ref={refs.setFloating}
          style={floatingStyles}
          {...getFloatingProps()}
          id={headingId}
          className="absolute z-50 px-3 py-1.5 text-xs text-white bg-slate-900 rounded-md shadow-lg pointer-events-none max-w-xs"
        >
          {label}
        </div>
      )}
    </>
  );
};

Step 2: Breaking Down the Accessibility Hooks

Let’s analyze why this implementation succeeds where standard custom onMouseEnter/onFocus handlers often fail:

  • useHover(context, { handleClose: safePolygon() }): Without safePolygon, if a user tries to move their mouse from the button into the tooltip popup, the mouse briefly enters whitespace, triggering a mouseleave event that instantly closes the tooltip. safePolygon creates an invisible trapezoid corridor allowing smooth cursor transition.
  • useFocus(context): Ensures that keyboard users tabbing through a form or navigation menu trigger the exact same visual state as mouse users.
  • useDismiss(context): Automatically listens for the Escape key globally when the tooltip is active, dismissing it instantly and returning full interaction control back to the page.
  • useRole(context, { role: 'tooltip' }): Automatically applies the correct ARIA role mapping so assistive technology understands the element’s semantic context.

Wiring Up ARIA Attributes Properly

To pass WCAG criteria for tooltips, screen readers must explicitly know that the focused or hovered element is described by the tooltip popup content.

Floating-UI simplifies this by automatically generating and assigning aria-describedby relationships. When the trigger element receives focus or hover state, the underlying reference props inject the corresponding ID matching our tooltip’s DOM node ID (headingId).

Let’s review the rendered DOM state when open:

<!-- Trigger Element -->
<button 
  aria-describedby=":r1:" 
  type="button"
>
  Delete Item
</button>

<!-- Tooltip Element -->
<div 
  id=":r1:" 
  role="tooltip"
  style="position: absolute; top: 120px; left: 340px;"
>
  Permanently remove this file from storage
</div>

When a screen reader user tabs to the button, it reads: “Delete Item, button. Permanently remove this file from storage.”


Enhancing with Floating Arrows and Polishing

Often, designers prefer a visual arrow pointing from the tooltip bubble back to the trigger element. We can easily integrate @floating-ui/react’s arrow middleware and <FloatingArrow /> component.

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

interface TooltipProps {
  label: string;
  children: ReactElement;
  placement?: Placement;
}

export const Tooltip: React.FC<TooltipProps> = ({
  label,
  children,
  placement = *'top'*,
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const arrowRef = useRef(null);

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

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

  const tooltipId = useId();

  return (
    <>
      {cloneElement(
        children,
        getReferenceProps({
          ref: refs.setReference,
          ...children.props,
        })
      )}
      {isOpen && (
        <div
          ref={refs.setFloating}
          style={floatingStyles}
          {...getFloatingProps()}
          id={tooltipId}
          className="px-3 py-1.5 text-xs font-medium text-white bg-slate-900 rounded shadow-md pointer-events-none"
        >
          {label}
          <FloatingArrow
            ref={arrowRef}
            context={context}
            className="fill-slate-900"
          />
        </div>
      )}
    </>
  );
};

Usage Example

Using our newly minted component across your application is declarative and clean:

import React from *'react'*;
import { Tooltip } from *'./Tooltip'*;
import { Info, Trash2 } from *'lucide-react'*;

export function Dashboard() {
  return (
    <div className="p-8 flex gap-4 items-center">
      <Tooltip label="View account statistics and telemetry">
        <button className="p-2 rounded-lg bg-slate-100 hover:bg-slate-200 focus:outline-none focus:ring-2 focus:ring-blue-500">
          <Info className="w-5 h-5 text-slate-700" />
        </button>
      </Tooltip>

      <Tooltip label="This action cannot be undone" placement="right">
        <button className="p-2 rounded-lg bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-500">
          <Trash2 className="w-5 h-5 text-red-600" />
        </button>
      </Tooltip>
    </div>
  );
}

Common Pitfalls to Avoid

  1. Putting Interactive Content Inside Tooltips: Tooltips are strictly meant for short, non-interactive text descriptions. If your popup contains links, buttons, or form controls, you should implement an Accessible Popover or Menu component instead. Putting focusable elements inside an element mapped with role="tooltip" creates a severe keyboard trap for screen reader users.
  2. Failing to Handle Mobile Viewports: Touch screens do not have “hover” states. By ensuring useFocus and useDismiss are configured, users tapping triggers can still invoke and dismiss tooltips reliably.
  3. Hardcoding Positions: Never rely on CSS position: absolute relative to a static parent container for tooltips. Dynamic page resizing, scrolling, and flex containers will inevitably cause tooltips to clip. Always rely on coordinate engines like Floating UI.

Conclusion

Building accessible design system primitives requires looking past the surface visuals. By combining React’s cloning patterns, TypeScript safety, @floating-ui/react’s robust positioning logic, and proper ARIA wiring, you create a tooltip that works seamlessly for mouse users, keyboard navigators, and screen reader users alike.

More posts