All posts
19 Oct 2026

Building an Accessible Tooltip in React: Hover, Focus, and ARIA DescribedBy

A deep-dive technical walkthrough on implementing a robust React tooltip from scratch, covering dual-trigger handling, dynamic positioning, and screen reader announcements.

Building an Accessible Tooltip in React: Hover, Focus, and ARIA DescribedBy

Tooltips are ubiquitous interface elements. They provide supplementary context, definitions, or action hints when a user hovers over an element. However, building a truly accessible tooltip is surprisingly challenging.

Many naive implementations only listen for mouse events (onMouseEnter and onMouseLeave), completely ignoring keyboard users who navigate via Tab. Furthermore, they often fail to handle screen reader accessibility trees correctly, relying on native title attributes that suffer from inconsistent browser rendering delays, lack of styling control, and poor mobile support.

In this deep-dive, we will build a production-ready, highly accessible Tooltip component in React using TypeScript. We will cover:

  1. Dual-trigger event handling for mouse hover and keyboard focus.
  2. Dynamic positioning with boundary collision detection.
  3. Screen reader integration using aria-describedby.
  4. Clean lifecycle and memory management.

The Architecture of an Accessible Tooltip

To ensure our tooltip works universally, it must satisfy three core interaction pillars:

  • Mouse Parity: Moving the mouse over the trigger shows the tooltip; moving it away hides it.
  • Keyboard Parity: Focusing the trigger via keyboard navigation (Tab) shows the tooltip; blurring it (Shift + Tab or clicking away) hides it. Pressing Escape should also immediately dismiss it.
  • Screen Reader Parity: Assistive technologies must immediately recognize the association between the trigger element and the tooltip content without relying solely on visual cues.

Let’s start by defining our TypeScript interfaces for the component props.

tsx
import React, { ReactNode, useState, useRef, useEffect, useId } from 'react';

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

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

Step 1: Handling Dual Triggers (Hover & Focus)

To capture both mouse and keyboard interactions seamlessly, we need to wrap our trigger element (the children) and attach event handlers to it. Instead of forcing the consumer to pass custom refs, we will clone the child element using React.cloneElement and inject our state management handlers.

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

  const showTooltip = () => {
    if (timeoutRef.current) clearTimeout(timeoutRef.current);
    timeoutRef.current = setTimeout(() => {
      setIsVisible(true);
    }, delay);
  };

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

  const clearTimeouts = () => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
      timeoutRef.current = null;
    }
  };

  // Cleanup timeouts on unmount
  useEffect(() => {
    return () => clearTimeouts();
  }, []);

  // Keyboard escape listener
  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape' && isVisible) {
        hideTooltip();
      }
    };
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isVisible]);

  // ... continued below
};

By managing a configurable delay, we prevent the tooltip from flashing aggressively when users rapidly sweep their mouse across the interface.


Step 2: Wiring up ARIA DescribedBy

Accessibility relies heavily on semantic markup. Screen readers need to know that the focused or hovered element is described by the tooltip popup. We achieve this using the aria-describedby attribute.

We connect the trigger to the tooltip popup container using the unique ID generated by React’s useId() hook.

  const triggerProps = {
    onMouseEnter: showTooltip,
    onMouseLeave: hideTooltip,
    onFocus: showTooltip,
    onBlur: hideTooltip,
    'aria-describedby': isVisible ? tooltipId : undefined,
  };

  const clonedTrigger = React.cloneElement(children, { ...triggerProps });

When isVisible is true, aria-describedby points directly to the tooltip’s DOM node. When hidden, the attribute is omitted to keep the accessibility tree clean.


Step 3: Dynamic Positioning and Collision Detection

Absolute positioning based purely on CSS often fails when tooltips collide with the edges of the viewport. We need a calculation step that checks the bounding client rect of both the trigger and the tooltip popup, adjusting coordinates dynamically.

import React, { ReactElement, useState, useRef, useEffect, useId, useLayoutEffect } from 'react';

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>(null);
  const tooltipRef = useRef<HTMLDivElement | null>(null);
  
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
  const tooltipId = useId();

  const showTooltip = () => {
    if (timeoutRef.current) clearTimeout(timeoutRef.current);
    timeoutRef.current = setTimeout(() => {
      setIsVisible(true);
    }, delay);
  };

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

  useLayoutEffect(() => {
    if (!isVisible || !triggerRef.current || !tooltipRef.current) return;

    const triggerRect = triggerRef.current.getBoundingClientRect();
    const tooltipRect = tooltipRef.current.getBoundingClientRect();

    let top = 0;
    let left = 0;

    const spacing = 8;

    switch (position) {
      case 'top':
        top = triggerRect.top - tooltipRect.height - spacing;
        left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
        break;
      case 'bottom':
        top = triggerRect.bottom + spacing;
        left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
        break;
      case 'left':
        top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
        left = triggerRect.left - tooltipRect.width - spacing;
        break;
      case 'right':
        top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
        left = triggerRect.right + spacing;
        break;
    }

    // Basic Viewport Collision Detection (Boundary clamping)
    const padding = 12;
    if (left < padding) {
      left = padding;
    } else if (left + tooltipRect.width > window.innerWidth - padding) {
      left = window.innerWidth - tooltipRect.width - padding;
    }

    if (top < padding) {
      // Fallback to bottom if top overflows viewport
      top = triggerRect.bottom + spacing;
    }

    setCoords({ top: top + window.scrollY, left: left + window.scrollX });
  }, [isVisible, position]);

  // Attach refs properly to cloned element
  const handleRef = (node: HTMLElement | null) => {
    triggerRef.current = node;
    const childRef = (children as any).ref;
    if (typeof childRef === 'function') {
      childRef(node);
    } else if (childRef) {
      childRef.current = node;
    }
  };

  const triggerProps = {
    ref: handleRef,
    onMouseEnter: showTooltip,
    onMouseLeave: hideTooltip,
    onFocus: showTooltip,
    onBlur: hideTooltip,
    'aria-describedby': isVisible ? tooltipId : undefined,
  };

  const clonedTrigger = React.cloneElement(children, triggerProps);

  return (
    <>
      {clonedTrigger}
      {isVisible && (
        <div
          ref={tooltipRef}
          id={tooltipId}
          role="tooltip"
          style={{
            position: 'absolute',
            top: `${coords.top}px`,
            left: `${coords.left}px`,
            zIndex: 1000,
          }}
          className="tooltip-popup"
        >
          {content}
        </div>
      )}
    </>
  );
};

Note on Ref Forwarding: When using React.cloneElement, it is crucial to handle existing refs gracefully so that the consumer’s original ref isn’t overwritten. Our handleRef helper function bridges this gap effectively.


Step 4: Styling and Polish

To make our tooltip look sleek and modern, we apply simple CSS transitions for smooth entry and exit animations. We also use role="tooltip", which instructs screen readers to treat the element explicitly as a contextual popup.

.tooltip-popup {
  background-color: #1e293b;
  color: #f8fafc;
  padding: 6px 12px;
  border-radius: 6px;
  font-size: 0.875rem;
  line-height: 1.25;
  pointer-events: none;
  box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
  white-space: nowrap;
  opacity: 0;
  animation: tooltipFadeIn 150ms ease-in-out forwards;
}

@keyframes tooltipFadeIn {
  to {
    opacity: 1;
  }
}

Conclusion

Building an accessible React tooltip requires thinking beyond simple hover states. By combining:

  • Dual-trigger event listeners for mouse and keyboard users,
  • Dynamic coordinate calculation to prevent viewport overflow, and
  • Semantic ARIA attributes (aria-describedby and role="tooltip") for screen readers,

We ensure that our UI components remain inclusive, robust, and delightful to use for everyone.

You can easily extend this foundation further by introducing portals (ReactDOM.createPortal) to render tooltips directly into document roots, avoiding clipping issues inside overflow: hidden containers.

More posts