Skip to content

Tooltip

SM · 950B gzip budget

A hover/focus label bound with aria-describedby, keyboard accessible.

Category
Overlays
Budget
sm, single-purpose control
External runtime
React / React DOM only
axe fixtureSSR rendersource scanbundle budget

Preview

Live fixture
Preview locale

Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.

Install

npx shadcn@latest add https://gear5-ui.vercel.app/r/tooltip.json

Copies the source into your project. Pulls in 1 shared registry item: cn.

New here? Set up Tailwind and import aliases first →

Source & props

Edit on GitHub ↗

Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.

"use client";

import { cloneElement, isValidElement, useCallback, useId, useRef, useState } from "react";
import { cn } from "../lib/cn";

export interface TooltipProps {
  content: string;
  children: React.ReactElement;
  side?: "top" | "bottom";
  className?: string;
}

/**
 * A hover/focus label bound with `aria-describedby`, not a native `title`
 * attribute — `title` is invisible to touch and keyboard users and its
 * timing is outside the page's control. Opens on focus as well as hover, so
 * the tooltip is not a mouse-only feature, and closes on Escape.
 */
export function Tooltip({ content, children, side = "top", className }: TooltipProps) {
  const [open, setOpen] = useState(false);
  const id = useId();
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

  // Hooks run unconditionally, before the early return below — the debounce
  // timer these close over is only ever read inside a real event handler
  // (mouseenter/focus/keydown), never during render, but static analysis
  // cannot see that far through cloneElement's prop object.
  const show = useCallback(() => {
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setOpen(true), 200);
  }, []);
  const hide = useCallback(() => {
    clearTimeout(timer.current);
    setOpen(false);
  }, []);
  const hideOnEscape = useCallback(
    (event: React.KeyboardEvent) => {
      if (event.key === "Escape") hide();
    },
    [hide],
  );

  if (!isValidElement(children)) return children;

  // show/hide/hideOnEscape are stable event-handler callbacks that only ever
  // run later, on user interaction — never during this render.
  // eslint-disable-next-line react-hooks/refs
  const trigger = cloneElement(children as React.ReactElement<Record<string, unknown>>, {
    "aria-describedby": open ? id : undefined,
    onMouseEnter: show,
    onMouseLeave: hide,
    onFocus: show,
    onBlur: hide,
    onKeyDown: hideOnEscape,
  });

  return (
    <span className="relative inline-block">
      {trigger}
      {open && (
        <span
          role="tooltip"
          id={id}
          className={cn(
            "absolute start-1/2 z-10 -translate-x-1/2 rtl:translate-x-1/2 whitespace-nowrap rounded-md bg-neutral-900 px-2 py-1 text-xs text-white dark:bg-neutral-100 dark:text-neutral-900",
            side === "top" ? "bottom-full mb-2" : "top-full mt-2",
            className,
          )}
        >
          {content}
        </span>
      )}
    </span>
  );
}

What CI checks

Quality contract
  • Bundled, minified and gzipped against its tier budget
  • Audited by axe in the state previewed above
  • Rendered through react-dom/server with no browser globals
  • Scanned for network calls, dangerous sinks, and unguarded animation