DEV Community

Cover image for Improving Accessibility - Tooltip
hritickjaiswal
hritickjaiswal

Posted on

Improving Accessibility - Tooltip

Tooltips look simple.

They are not.

If you’ve ever tried building one properly, you’ll realize quickly that it’s not about styling — it’s about behavior.

In this article, I’ll walk through a simple accessible Tooltip implementation in React and focus only on three important aspects:

  • ⏱ Time interval handling (prevent flicker)
  • ⌨ Escape key handling (keyboard accessibility)
  • 📍 CSS positioning (clean layout without layout hacks)

The Goal

We want a tooltip that:

  • Appears on hover and focus
  • Disappears on mouse leave and blur
  • Closes when pressing Escape
  • Doesn’t flicker on quick mouse movement
  • Is positioned cleanly using CSS

Implementation

import {
  useEffect,
  useRef,
  useState,
  cloneElement,
  isValidElement,
  type ReactElement,
  useId,
} from "react";

import styles from "./style.module.css";

interface TooltipProps {
  children: ReactElement;
  title: string;
}

const DELAY_INTERVAL = 200;

function Tooltip({ children, title }: TooltipProps) {
  const [showTitle, setShowTitle] = useState(false);
  const openTimer = useRef(-1);
  const closeTimer = useRef(-1);
  const tooltipId = useId();

  function show() {
    clearTimeout(openTimer.current);

    openTimer.current = setTimeout(() => setShowTitle(true), DELAY_INTERVAL);
  }

  function hide() {
    clearTimeout(closeTimer.current);
    clearTimeout(openTimer.current);

    closeTimer.current = setTimeout(() => setShowTitle(false), DELAY_INTERVAL);
  }

  useEffect(() => {
    function keydownHandler(e: KeyboardEvent) {
      const { key } = e;

      if (key === "Escape") {
        setShowTitle(false);
      }
    }

    if (showTitle) {
      document.addEventListener("keydown", keydownHandler);
    }

    return () => {
      if (showTitle) {
        document.removeEventListener("keydown", keydownHandler);
      }
    };
  }, [showTitle]);

  if (!isValidElement(children)) {
    throw new Error("Tooltip expects a single React element child");
  }

  const trigger = cloneElement(children, {
    onMouseEnter: (e) => {
      children.props.onMouseEnter?.(e);
      show();
    },
    onMouseLeave: (e) => {
      children.props.onMouseLeave?.(e);
      hide();
    },
    onFocus: (e) => {
      children.props.onFocus?.(e);
      show();
    },
    onBlur: (e) => {
      children.props.onBlur?.(e);
      hide();
    },
    "aria-describedby": showTitle ? tooltipId : undefined,
  });

  return (
    <div className={styles.container}>
      {trigger}

      {showTitle ? (
        <div className={styles.titleContainer}>
          <div role="tooltip" id={tooltipId} className={styles.titleWrapper}>
            {title}
          </div>
        </div>
      ) : null}
    </div>
  );
}

export default Tooltip;

Enter fullscreen mode Exit fullscreen mode

CSS

.container {
  position: relative;
}

.titleContainer {
  position: absolute;
  left: 50%;
  top: calc(100%);
  transform: translateX(-50%);
  padding-top: 8px;
}

.titleWrapper {
  padding: 0.25rem;
  background-color: #afafaf;
  color: #fff;
  border-radius: 0.25rem;
}

Enter fullscreen mode Exit fullscreen mode

Why this works well:

  • No layout shifts
  • No DOM measurements
  • No scroll listeners
  • No portal complexity

Top comments (0)