All posts
29 Sep 2026

Hover and Hear: Crafting an Accessible Tooltip Component in React

Learn how to build a fully accessible, custom tooltip component in React featuring dynamic positioning, keyboard support, and robust ARIA attributes without heavy external libraries.

Hover and Hear: Crafting an Accessible Tooltip Component in React

Tooltips are ubiquitous in modern web interfaces. They provide supplemental context, define iconography, or clarify dense UI layouts. Yet, despite their widespread usage, they are frequently implemented incorrectly—relying on native title attributes that suffer from unpredictable delays, zero styling flexibility, and abysmal screen reader support.

Building a custom tooltip component in React allows us to take control of styling, animations, and behaviors. However, this freedom introduces responsibility. To make our custom tooltip truly production-ready, it must:

  1. Be accessible to screen reader users via proper ARIA relationships (aria-describedby).
  2. Be fully operable via keyboard navigation (triggered on focus, dismissed on Escape).
  3. Support hover intents to avoid flickering.
  4. Handle screen boundaries gracefully with dynamic collision detection.

In this deep dive, we will build a lightweight, robust, and fully accessible tooltip component in React and TypeScript from scratch.


The Anatomy of an Accessible Tooltip

Before writing code, let’s look at the accessibility contract required for tooltips as defined by the WAI-ARIA Authoring Practices Guide (APG):

  • The Trigger Element: Must expose the relationship to the tooltip using aria-describedby pointing to the ID of the tooltip element.
  • The Tooltip Popup: Must have a unique ID matching the trigger’s aria-describedby and should ideally carry role="tooltip" (though modern screen readers infer this well from context, explicit roles or semantic containers help).
  • Focus Management: The tooltip appears when the trigger receives keyboard focus or mouse hover, and disappears when it loses focus, mouse hover, or when the user presses the Escape key.

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

tsx
import React, { useState, useRef, useEffect, ReactNode, cloneElement, isValidElement } from 'v';

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

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

Crafting the Component Base

We want our tooltip to wrap any arbitrary child element (a button, an icon, a link) without forcing an extra wrapper div into the DOM layout if possible. To achieve this cleanly, we can clone the child element and inject our event handlers and ARIA attributes directly onto it.

export const Tooltip: React.FC<TooltipProps> = ({
  content,
  position = 'top',
  delay = 200,
  children,
}) => {
  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 = useRef(`tooltip-${Math.random().toString(36.substring(2, 9))}`).current;

  // ... state logic goes here
};

Handling Hover and Focus States

To ensure both mouse users and keyboard users get a seamless experience, we must handle pointer events (onMouseEnter, onMouseLeave) and focus events (onFocus, onBlur) simultaneously. We also implement a delay mechanism to prevent the tooltip from flashing annoyingly as the user sweeps their cursor across the screen.

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

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

  // Handle Escape key dismissal
  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape' && isVisible) {
        hideTooltip();
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isVisible]);

Dynamic Positioning and Collision Detection

Static positioning classes (like absolute bottom-full) fail when a component gets close to the edge of the viewport, cutting off the tooltip text. We can calculate coordinates dynamically using getBoundingClientRect right before the tooltip renders.

  useEffect(() => {
    if (isVisible && triggerRef.current && tooltipRef.current) {
      const triggerRect = triggerRef.current.getBoundingClientRect();
      const tooltipRect = tooltipRef.current.getBoundingClientRect();
      const scrollX = window.scrollX;
      const scrollY = window.scrollY;

      let top = 0;
      let left = 0;

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

      // Basic Viewport Collision Detection for X-axis
      if (left < 8) {
        left = 8;
      } else if (left + tooltipRect.width > window.innerWidth - 8) {
        left = window.innerWidth - tooltipRect.width - 8;
      }

      setCoords({ top, left });
    }
  }, [isVisible, position]);

Putting It All Together

Now, let’s assemble the complete component, utilizing React portals to render the tooltip directly into document.body. This guarantees that our tooltips never get clipped by parent containers with overflow: hidden or stacking context issues (z-index).

import React, { useState, useRef, useEffect, ReactNode, cloneElement, isValidElement } from 'react';
import { createPortal } from 'react-dom';

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

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

export const Tooltip: React.FC<TooltipProps> = ({
  content,
  position = 'top',
  delay = 200,
  children,
}) => {
  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 = useRef(`tooltip-${Math.random().toString(36).substring(2, 9)}`).current;

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

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

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

  useEffect(() => {
    if (isVisible && triggerRef.current && tooltipRef.current) {
      const triggerRect = triggerRef.current.getBoundingClientRect();
      const tooltipRect = tooltipRef.current.getBoundingClientRect();
      const scrollX = window.scrollX;
      const scrollY = window.scrollY;

      let top = 0;
      let left = 0;

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

      setCoords({ top, left });
    }
  }, [isVisible, position]);

  if (!isValidElement(children)) {
    return <>{children}</>;
  }

  const childProps = children.props as any;

  const triggerElement = cloneElement(children, {
    ref: (node: HTMLElement) => {
      triggerRef.current = node;
      const { ref } = children as any;
      if (typeof ref === 'function') ref(node);
      else if (ref) ref.current = node;
    },
    'aria-describedby': isVisible ? tooltipId : undefined,
    onMouseEnter: (e: React.MouseEvent) => {
      childProps.onMouseEnter?.(e);
      showTooltip();
    },
    onMouseLeave: (e: React.MouseEvent) => {
      childProps.onMouseLeave?.(e);
      hideTooltip();
    },
    onFocus: (e: React.FocusEvent) => {
      childProps.onFocus?.(e);
      showTooltip();
    },
    onBlur: (e: React.FocusEvent) => {
      childProps.onBlur?.(e);
      hideTooltip();
    },
  });

  return (
    <>
      {triggerElement}
      {isVisible &&
        createPortal(
          <div
            ref={tooltipRef}
            id={tooltipId}
            role="tooltip"
            style={{
              position: 'absolute',
              top: `${coords.top}px`,
              left: `${coords.left}px`,
              zIndex: 9999,
            }}
            className="px-3 py-1.5 text-xs font-medium text-white bg-slate-900 rounded shadow-lg pointer-events-none transition-opacity duration-150"
          >
            {content}
          </div>,
          document.body
        )}
    </>
  );
};

Conclusion

By taking matters into our own hands, we’ve created a custom React tooltip component that balances gorgeous UI flexibility with rigid accessibility standards.

Key Takeaways:

  • Always link the trigger element and the tooltip element using matching aria-describedby and id attributes.
  • Combine mouse events and focus events to guarantee keyboard accessibility.
  • Use ReactDOM.createPortal combined with viewport bounding client measurements to prevent clipping bugs.

Now your users can hover and hear your tooltips reliably, regardless of whether they are navigating via mouse, trackpad, or screen reader.

More posts