All posts
1 Oct 2026

Hover and Hide: Building an Accessible Tooltip Component in React from Scratch

Learn how to build a production-grade, fully accessible tooltip component in React with proper ARIA attributes, keyboard support, and dynamic positioning.

Hover and Hide: Building an Accessible Tooltip Component in React from Scratch

Tooltips are among the most ubiquitous patterns in modern web UI. They provide helpful, contextual hints when users hover over or focus on an element. Yet, despite their simplicity, they are frequently implemented incorrectly—breaking screen readers, trapping keyboard users, or vanishing when the user tries to interact with them.

In this post, we will build a production-grade, fully accessible tooltip component in React and TypeScript from scratch. We will handle aria-describedby relationships, manage dual trigger states (hover and focus), implement keyboard escape dismissal, and calculate dynamic viewport positioning without relying on heavy third-party libraries like Popper.js or Floating UI.


The Anatomy of an Accessible Tooltip

Before writing code, let’s establish what makes a tooltip accessible according to the WAI-ARIA Authoring Practices Guide (APG):

  1. Semantic Connection: The trigger element must reference the tooltip container via aria-describedby, linking their IDs so screen readers announce the description when the trigger receives focus or is hovered.
  2. State Management: The tooltip must appear on mouse hover and keyboard focus, and disappear on mouse leave, keyboard blur, or when the user presses the Escape key.
  3. Non-Interactive Nature: Standard tooltips are static informational containers. They should not receive keyboard focus themselves; focus must remain on the trigger.
  4. No Visual Clipping: The tooltip must dynamically adjust its position so it never overflows the visible viewport boundaries.

Step 1: Defining the Types and State

Let’s start by defining our TypeScript interface. Our Tooltip component will accept a label (the text to display), a placement preference, and standard children.

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

type Placement = 'top' | 'bottom' | 'left' | 'right';

interface TooltipProps {
  content: ReactNode;
  placement?: Placement;
  children: React.ReactElement;
  delay?: number;
}

Next, inside the component, we need state for tracking visibility and a unique ID to satisfy the aria-describedby contract.

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

  // Implementation details follow...
};

Step 2: Managing Triggers and Keyboard Dismissal

A robust tooltip must respond to both mouse and keyboard interactions seamlessly. We also need to listen for the Escape key to instantly dismiss the tooltip if it’s open.

  const showTooltip = () => {
    if (timeoutRef.current) clearTimeout(timeoutRef.current);
    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]);

By attaching showTooltip to onMouseEnter and onFocus, and hideTooltip to onMouseLeave and onBlur, we cover both mouse users and keyboard tab-navigation users uniformly.


Step 3: Dynamic Viewport Positioning

Calculating coordinates manually gives us lightweight control over positioning without adding heavy dependencies. When the tooltip becomes visible, we measure the bounding client rectangles of both the trigger element and the tooltip container.

  useEffect(() => {
    if (!isVisible) return;

    const trigger = triggerRef.current;
    const tooltip = tooltipRef.current;

    if (!trigger || !tooltip) return;

    const triggerRect = trigger.getBoundingClientRect();
    const tooltipRect = tooltip.getBoundingClientRect();

    let top = 0;
    let left = 0;
    const gap = 8; // Distance between trigger and tooltip

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

    // Basic viewport boundary correction
    const padding = 12;
    if (left < padding) {
      left = padding;
    } else if (left + tooltipRect.width > window.innerWidth - padding) {
      left = window.innerWidth - tooltipRect.width - padding;
    }

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

Step 4: Assembling the Component and Cloning Children

To inject the required accessibility attributes (aria-describedby) and event handlers directly into the user’s child element without forcing an unnecessary wrapper div into the DOM, we can use React’s cloneElement utility.

  const clonedChild = cloneElement(children, {
    ref: (node: HTMLElement) => {
      triggerRef.current = node;
      // Preserve existing refs if passed
      const { ref } = children;
      if (typeof ref === 'function') ref(node);
      else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;
    },
    'aria-describedby': isVisible ? tooltipId : undefined,
    onMouseEnter: (e: React.MouseEvent) => {
      children.props.onMouseEnter?.(e);
      showTooltip();
    },
    onMouseLeave: (e: React.MouseEvent) => {
      children.props.onMouseLeave?.(e);
      hideTooltip();
    },
    onFocus: (e: React.FocusEvent) => {
      children.props.onFocus?.(e);
      showTooltip();
    },
    onBlur: (e: React.FocusEvent) => {
      children.props.onBlur?.(e);
      hideTooltip();
    },
  });

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

Step 5: Polishing with CSS

A touch of CSS ensures smooth transitions and clean visual presentation.

.tooltip-container {
  background-color: #1f2937;
  color: #f9fafb;
  padding: 0.375rem 0.75rem;
  font-size: 0.875rem;
  line-height: 1.25rem;
  border-radius: 0.375rem;
  box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
  pointer-events: none;
  white-space: nowrap;
  animation: tooltip-fadein 150ms ease-out;
}

@keyframes tooltip-fadein {
  from {
    opacity: 0;
    transform: scale(0.95);
  }
  to {
    opacity: 1;
    transform: scale(1);
  }
}

Conclusion

By taking the time to implement proper ARIA roles, dual event triggers, viewport boundary checking, and keyboard dismissal via the Escape key, we’ve created a bulletproof tooltip component. It respects screen readers, behaves predictably for keyboard-only navigators, and maintains high performance without heavy UI libraries.

You can extend this foundation further by adding collision detection for flipping placements automatically when hitting viewport edges, or by supporting rich HTML content. Happy coding!

More posts