All posts
26 Sep 2026

Hover, Focus, Announce: Crafting an Accessible Tooltip in React

Learn how to build a production-ready, fully accessible custom tooltip in React using TypeScript, handling robust mouse/keyboard focus states and proper ARIA wiring.

Hover, Focus, Announce: Crafting an Accessible Tooltip in React

Tooltips are ubiquitous in modern web interfaces. They provide supplementary context when users hover over an element. However, they are also one of the most frequently broken UI components when it comes to web accessibility (a11y).

A truly accessible tooltip must satisfy several conditions beyond simply appearing on mouseenter:

  1. Keyboard Parity: Users navigating via keyboard (Tab) must trigger the tooltip on focus and dismiss it on blur or Escape.
  2. Screen Reader Announcements: Assistential technologies must automatically read the tooltip content via proper aria-describedby wiring.
  3. State Management: Hovering out, focusing away, or pressing escape must gracefully hide the tooltip without race conditions.
  4. Robust Positioning: The tooltip should never clip outside the viewport.

In this technical walkthrough, we will build a robust, accessible, custom Tooltip component in React using TypeScript and React portals.


The Anatomy of an Accessible Tooltip

Before diving into code, let’s examine the DOM relationship required by the W3C ARIA Authoring Practices Guide (APG) for tooltips:

  • The trigger element receives aria-describedby="[tooltip-id]".
  • The tooltip container receives role="tooltip" and a matching id.
  • The tooltip is not a child of the trigger element in the accessibility tree (often solved using React Portals).

Step 1: Defining the TypeScript Interfaces

Let’s start by defining our props. We want our component to accept a React node for the trigger, string or node content for the tooltip, and optional positioning configuration.

tsx
import React, { ReactNode, ReactElement } from 'react';

export type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';

export interface TooltipProps {
  content: ReactNode;
  children: ReactElement;
  position?: TooltipPosition;
  delay?: number;
}

Step 2: Building the Core Component Logic

Next, we implement the state management. We need to handle four primary interaction events:

  • onMouseEnter / onMouseLeave for mouse users.
  • onFocus / onBlur for keyboard and screen reader users.
  • onKeyDown to listen for the Escape key, allowing users to dismiss the tooltip without moving focus.
import React, { useState, useRef, useId, cloneElement } from 'react';
import { createPortal } from 'react-dom';
import './Tooltip.css';

export const Tooltip: React.FC<TooltipProps> = ({
  content,
  children,
  position = 'top',
  delay = 200,
}) => {
  const [isVisible, setIsVisible] = useState(false);
  const [coords, setCoords] = useState({ top: 0, left: 0 });
  
  const triggerRef = useRef<HTMLElement>(null);
  const tooltipRef = useRef<HTMLDivElement>(null);
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
  
  const tooltipId = useId();

  const showTooltip = () => {
    if (timeoutRef.current) clearTimeout(timeoutRef.current);
    timeoutRef.current = setTimeout(() => {
      if (triggerRef.current) {
        const rect = triggerRef.current.getBoundingClientRect();
        calculatePosition(rect, position);
        setIsVisible(true);
      }
    }, delay);
  };

  const hideTooltip = () => {
    if (timeoutRef.current) clearTimeout(timeoutRef.current);
    setIsVisible(false);
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Escape') {
      hideTooltip();
    }
  };

  const calculatePosition = (rect: DOMRect, pos: TooltipPosition) => {
    // Basic positioning logic based on trigger bounding rect
    let top = 0;
    let left = 0;

    const scrollY = window.scrollY;
    const scrollX = window.scrollX;

    switch (pos) {
      case 'top':
        top = rect.top + scrollY - 8;
        left = rect.left + scrollX + rect.width / 2;
        break;
      case 'bottom':
        top = rect.bottom + scrollY + 8;
        left = rect.left + scrollX + rect.width / 2;
        break;
      case 'left':
        top = rect.top + scrollY + rect.height / 2;
        left = rect.left + scrollX - 8;
        break;
      case 'right':
        top = rect.top + scrollY + rect.height / 2;
        left = rect.right + scrollX + 8;
        break;
    }

    setCoords({ top, left });
  };

  // Cloning child to inject accessibility attributes safely
  const childProps = {
    ref: triggerRef,
    onMouseEnter: () => {
      showTooltip();
      children.props.onMouseEnter?.();
    },
    onMouseLeave: () => {
      hideTooltip();
      children.props.onMouseLeave?.();
    },
    onFocus: () => {
      showTooltip();
      children.props.onFocus?.();
    },
    onBlur: () => {
      hideTooltip();
      children.props.onBlur?.();
    },
    onKeyDown: (e: React.KeyboardEvent) => {
      handleKeyDown(e);
      children.props.onKeyDown?.(e);
    },
    'aria-describedby': isVisible ? tooltipId : undefined,
  };

  return (
    <>
      {cloneElement(children, childProps)}
      {isVisible &&
        createPortal(
          <div
            ref={tooltipRef}
            id={tooltipId}
            role="tooltip"
            className={`tooltip tooltip-${position}`}
            style={{
              top: `${coords.top}px`,
              left: `${coords.left}px`,
            }}
          >
            {content}
          </div>,
          document.body
        )}
    </>
  );
};

Step 3: Styling and Positioning Coordinates

To ensure the tooltip anchors properly (centered relative to the trigger element), we add corresponding CSS classes. Using CSS transforms allows us to easily offset the element from its calculated coordinate origin.

.tooltip {
  position: absolute;
  z-index: 9999;
  padding: 6px 12px;
  background-color: #1a1a1a;
  color: #ffffff;
  font-size: 0.875rem;
  border-radius: 4px;
  pointer-events: none;
  white-space: nowrap;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
  transition: opacity 0.15s ease-in-out;
}

.tooltip-top {
  transform: translate(-50%, -100%);
}

.tooltip-bottom {
  transform: translate(-50%, 0);
}

.tooltip-left {
  transform: translate(-100%, -50%);
}

.tooltip-right {
  transform: translate(0, -50%);
}

Step 4: Testing Screen Reader Compatibility

When a screen reader user tabs onto the element wrapped by our Tooltip component, the browser reads out the element’s label followed immediately by the text contained inside the portal node linked via aria-describedby="[tooltipId]".

Usage Example

import React from 'react';
import { Tooltip } from './Tooltip';

export function App() {
  return (
    <div style={{ padding: '100px', textAlign: 'center' }}>
      <Tooltip content="Deletes this item permanently" position="top">
        <button className="delete-btn">
          Delete Item
        </button>
      </Tooltip>
    </div>
  );
}

Best Practices & Edge Cases Handled

  1. Portal Rendering: By rendering the tooltip into document.body via createPortal, we prevent clipping issues caused by parent containers with overflow: hidden or restrictive z-index hierarchies.
  2. Escape Hatch: Pressing the Escape key immediately closes the tooltip without forcing the user to move focus away from the interactive element.
  3. Debounced Transitions: Using setTimeout prevents annoying flickering when a user rapidly moves their mouse across adjacent interactive items.

Conclusion

Building accessible UI components requires looking beyond the visual state. By combining React Portals, clean keyboard event interception, and precise ARIA attribute wiring, you can deliver a tooltip experience that feels delightful to mouse users and completely seamless to screen reader and keyboard-only users alike.

More posts