All posts
17 Oct 2026

Building an Accessible Tooltip in React: Hover, Focus, and Screen Reader Patterns

Learn how to build a production-ready, accessible tooltip component in React and TypeScript featuring full keyboard navigation, precise positioning, and proper ARIA attributes.

Building an Accessible Tooltip in React: Hover, Focus, and Screen Reader Patterns

Tooltips are among the most common UI components on the web, yet they are surprisingly easy to get wrong from an accessibility and interaction design standpoint. A poorly built tooltip can trap keyboard users, remain invisible to screen readers, or flicker confusingly when a user moves their mouse.

In this installment of our UI component series, we are going to build a production-ready, accessible Tooltip component in React and TypeScript. We’ll handle hover and focus states, manage precise positioning, establish correct aria-describedby relationships, and ensure users can dismiss the tooltip cleanly using the Escape key.


The Anatomy of an Accessible Tooltip

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

  1. Appears on Hover and Focus: It must trigger when the target element receives mouse hover or keyboard focus.
  2. Disappears on Escape: Pressing the Escape key must immediately dismiss the tooltip without moving focus away from the trigger.
  3. Does not trap focus: The tooltip itself does not receive keyboard focus; focus remains on the trigger element.
  4. Uses aria-describedby: The trigger element must reference the tooltip container via aria-describedby so screen readers announce its content when the trigger is focused.

Step 1: Defining the Types and Props

Let’s start by defining our TypeScript interface. We want our component to accept a content prop (which can be a string or React node), a positioning preference, and standard HTML attributes.

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

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

export interface TooltipProps extends HTMLAttributes<HTMLDivElement> {
  /** The content to display inside the tooltip */
  content: ReactNode;
  /** The element that triggers the tooltip */
  children: React.ReactElement;
  /** Preferred placement of the tooltip */
  position?: TooltipPosition;
  /** Delay in ms before showing the tooltip */
  enterDelay?: number;
  /** Delay in ms before hiding the tooltip */
  leaveDelay?: number;
}

Step 2: Managing State and Event Handlers

A robust tooltip requires careful coordination between mouse events (onMouseEnter, onMouseLeave), focus events (onFocus, onBlur), and keyboard events (onKeyDown). We also need support for enter and leave delays to prevent jittery UI behavior.

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

  const clearTimeouts = () => {
    if (enterTimeoutRef.current) clearTimeout(enterTimeoutRef.current);
    if (leaveTimeoutRef.current) clearTimeout(leaveTimeoutRef.current);
  };

  const handleShow = () => {
    clearTimeouts();
    enterTimeoutRef.current = setTimeout(() => {
      setIsVisible(true);
    }, enterDelay);
  };

  const handleHide = () => {
    clearTimeouts();
    leaveTimeoutRef.current = setTimeout(() => {
      setIsVisible(false);
    }, leaveDelay);
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Escape' && isVisible) {
      e.stopPropagation();
      setIsVisible(false);
    }
  };

  // We need to clone the child element to inject accessibility attributes and event handlers
  // ...
};

Step 3: Cloning the Trigger and Injecting ARIA Attributes

To avoid wrapping the trigger element in an unnecessary <span> or <div> (which can break flexbox or grid layouts), we use React.cloneElement to inject the necessary ARIA attributes and event listeners directly into the child element.

  const child = React.Children.only(children);

  const triggerProps = {
    'aria-describedby': isVisible ? tooltipId : undefined,
    onMouseEnter: (e: React.MouseEvent) => {
      child.props.onMouseEnter?.(e);
      handleShow();
    },
    onMouseLeave: (e: React.MouseEvent) => {
      child.props.onMouseLeave?.(e);
      handleHide();
    },
    onFocus: (e: React.FocusEvent) => {
      child.props.onFocus?.(e);
      handleShow();
    },
    onBlur: (e: React.FocusEvent) => {
      child.props.onBlur?.(e);
      handleHide();
    },
    onKeyDown: (e: React.KeyboardEvent) => {
      child.props.onKeyDown?.(e);
      handleKeyDown(e);
    },
  };

Tip: Always remember to call the child’s original event handlers if they exist (child.props.onMouseEnter?.(e)). Failing to do this will silently break any existing event handling on the consumer’s component.


Step 4: Assembling the Component and Styling

Now, let’s render the cloned trigger along with the floating tooltip container. We’ll use CSS classes to handle positioning relative to the trigger.

  return (
    <div className="tooltip-wrapper" style={{ position: 'relative', display: 'inline-block' }}>
      {React.cloneElement(child, triggerProps)}
      
      {isVisible && (
        <div
          id={tooltipId}
          role="tooltip"
          className={`tooltip-bubble tooltip-${position} ${className}`}
          {...rest}
        >
          {content}
          <div className={`tooltip-arrow tooltip-arrow-${position}`} />
        </div>
      )}
    </div>
  );
};

Companion CSS Styles

Here is a clean, modern CSS implementation to position and style the tooltip bubble:

.tooltip-wrapper {
  position: relative;
  display: inline-block;
}

.tooltip-bubble {
  position: absolute;
  z-index: 1070;
  padding: 0.375rem 0.75rem;
  font-size: 0.875rem;
  line-height: 1.25;
  color: #ffffff;
  background-color: #1a202c;
  border-radius: 0.375rem;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
  white-space: nowrap;
  pointer-events: none;
  animation: tooltip-fade-in 150ms ease-out;
}

/* Positioning variants */
.tooltip-top {
  bottom: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%);
}

.tooltip-bottom {
  top: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%);
}

.tooltip-left {
  right: calc(100% + 8px);
  top: 50%;
  transform: translateY(-50%);
}

.tooltip-right {
  left: calc(100% + 8px);
  top: 50%;
  transform: translateY(-50%);
}

@keyframes tooltip-fade-in {
  from {
    opacity: 0;
    transform: translateX(-50%) scale(0.95);
  }
  to {
    opacity: 1;
    transform: translateX(-50%) scale(1);
  }
}

Step 5: Consuming the Tooltip

Using our newly built component is straightforward and integrates seamlessly with buttons, links, or custom icon components:

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

export function App() {
  return (
    <div style={{ padding: '4rem', textAlign: 'center' }}>
      <Tooltip content="Delete items permanently" position="top">
        <button 
          onClick={() => alert('Deleted!')}
          style={{ padding: '0.5rem 1rem', cursor: 'pointer' }}
        >
          Delete
        </button>
      </Tooltip>
    </div>
  );
}

Conclusion

By combining React’s cloneElement, precise ref and timer management, and strict adherence to WAI-ARIA authoring practices, we have built a tooltip that is robust, performant, and fully accessible.

Key Takeaways

  • Never trap focus: Tooltips should provide supplementary information without stealing keyboard focus.
  • Link with aria-describedby: Dynamically assign the ID of the tooltip container to the trigger when visible.
  • Support Escape: Always allow keyboard users to dismiss floating elements instantly using the Escape key.
  • Preserve child props: When wrapping children via cloneElement, ensure existing event handlers are correctly chained.

Stay tuned for the next article in our UI component series, where we will tackle building an accessible, fully keyboard-navigable Modal dialog!

More posts