Tooltip

Auto-flipping dark tooltip that wraps any element, shown on hover and focus.

import { Tooltip, TooltipProps }from "@/components/ui"

Specification

Wraps any element and shows an auto-flipping dark tooltip on hover and keyboard focus.

Props (3)
Every prop this component accepts, its type, default, and how to use it.
PropTypeDefaultDescription
content*ReactNode—The tooltip content shown on hover/focus.
children*ReactElement—The single element that anchors and triggers the tooltip.
delayMsnumber400Delay before the tooltip appears after hover/focus, in milliseconds.

On a button

Full source
The complete component, read from src/components/ui/tooltip.tsx.
tooltip.tsx
"use client";

import { useState, useRef, type ReactNode, type ReactElement } from "react";
import { createPortal } from "react-dom";
import { usePopoverPosition } from "@/hooks/use-popover-position";
import { useMounted } from "@/hooks/use-mounted";

export interface TooltipProps {

  content: ReactNode;
  children: ReactElement;

  delayMs?: number;
}

export function Tooltip({ content, children, delayMs = 400 }: TooltipProps) {
  const [open, setOpen] = useState(false);
  const anchorRef = useRef<HTMLSpanElement>(null);
  const mounted = useMounted();
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  const position = usePopoverPosition(anchorRef, open, {
    align: "center",
    gap: 6,
    minWidth: 60,
    preferredHeight: 40,
  });

  const show = () => {
    timerRef.current = setTimeout(() => setOpen(true), delayMs);
  };
  const hide = () => {
    if (timerRef.current) clearTimeout(timerRef.current);
    setOpen(false);
  };

  return (
    <>
      <span
        ref={anchorRef}
        className="inline-flex"
        onMouseEnter={show}
        onMouseLeave={hide}
        onFocus={show}
        onBlur={hide}
      >
        {children}
      </span>

      {open && position && mounted &&
        createPortal(
          <span
            role="tooltip"
            className="pointer-events-none z-50 max-w-56 whitespace-nowrap rounded-md bg-surface-dark px-2.5 py-1.5 text-xs leading-snug text-on-dark shadow-lg"
            style={position.style}
          >
            {content}
            <span
              aria-hidden="true"
              className="absolute left-1/2 size-2 -translate-x-1/2 rotate-45 bg-surface-dark"
              style={position.placement === "top" ? { bottom: -4 } : { top: -4 }}
            />
          </span>,
          document.body
        )}
    </>
  );
}

Dependencies

To use Tooltip you also need the following. Copy these into your project alongside it.

Recommended folder structure

project structure
src/components/ui/
  └─ tooltip.tsx
src/hooks/
  ├─ use-mounted.ts
  └─ use-popover-position.ts
src/lib/
  └─ utils.ts

Dependency files

These supporting files aren’t components — copy each one into the matching path above.

src/hooks/use-mounted.ts
use-mounted.ts
"use client";

import { useState, useEffect } from "react";

/**
 * Returns true once the component has mounted on the client.
 * Useful for portals and anything that needs `document` to exist.
 */
export function useMounted(): boolean {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  return mounted;
}
src/hooks/use-popover-position.ts
use-popover-position.ts
"use client";

import { useState, useEffect, useCallback, type RefObject, type CSSProperties } from "react";

interface PopoverPositionOptions {
  align?: "start" | "center" | "end";
  gap?: number;
  viewportPadding?: number;
  preferredHeight?: number;
  matchWidth?: boolean;
  minWidth?: number;
}

interface PopoverPosition {
  placement: "top" | "bottom";
  style: CSSProperties;
}

/**
 * Compute a fixed-position popover style anchored to a ref element.
 * Opens downward by default; flips upward when there isn't room below
 * AND there's more room above. Clamps horizontally so nothing renders
 * past the viewport edge. Re-measures on scroll/resize while open.
 */
export function usePopoverPosition(
  anchorRef: RefObject<HTMLElement | null>,
  open: boolean,
  options: PopoverPositionOptions = {}
): PopoverPosition | null {
  const {
    align = "start",
    gap = 4,
    viewportPadding = 8,
    preferredHeight = 320,
    matchWidth = false,
    minWidth,
  } = options;

  const [position, setPosition] = useState<PopoverPosition | null>(null);

  const measure = useCallback(() => {
    const anchor = anchorRef.current;
    if (!anchor) return;
    const rect = anchor.getBoundingClientRect();
    const vw = window.innerWidth;
    const vh = window.innerHeight;
    const spaceBelow = vh - rect.bottom - viewportPadding;
    const spaceAbove = rect.top - viewportPadding;
    const placement =
      spaceBelow < Math.min(preferredHeight, 160) && spaceAbove > spaceBelow
        ? "top"
        : "bottom";
    const maxHeight = Math.max(
      120,
      Math.min(preferredHeight, placement === "top" ? spaceAbove : spaceBelow)
    );
    const width = matchWidth ? rect.width : minWidth;
    let left = align === "end" && width ? rect.right - width : rect.left;
    if (align === "center" && width)
      left = rect.left + rect.width / 2 - width / 2;
    left = width
      ? Math.min(Math.max(left, viewportPadding), vw - width - viewportPadding)
      : Math.min(Math.max(left, viewportPadding), vw - viewportPadding);

    setPosition({
      placement,
      style: {
        position: "fixed",
        top: placement === "top" ? undefined : rect.bottom + gap,
        bottom: placement === "top" ? vh - rect.top + gap : undefined,
        left,
        width: matchWidth ? rect.width : undefined,
        minWidth: !matchWidth ? minWidth : undefined,
        maxHeight,
      },
    });
  }, [anchorRef, align, gap, viewportPadding, preferredHeight, matchWidth, minWidth]);

  useEffect(() => {
    if (!open) {
      setPosition(null);
      return;
    }
    measure();
    window.addEventListener("scroll", measure, true);
    window.addEventListener("resize", measure);
    return () => {
      window.removeEventListener("scroll", measure, true);
      window.removeEventListener("resize", measure);
    };
  }, [open, measure]);

  return position;
}
src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

/**
 * Merge class names with clsx semantics + Tailwind conflict resolution.
 * Prefer this in all components.
 */
export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}

/**
 * Concatenate class names, filtering out falsy values.
 * @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
 */
export function cx(...args: (string | false | null | undefined)[]): string {
  return args.filter(Boolean).join(" ");
}

/**
 * Focus ring style helper.
 * @deprecated Focus rings are now handled globally via the
 * `:focus-visible` rule in `globals.css` and the `focus-visible:*`
 * utilities. This remains only for backwards compatibility.
 */
export function ring(color: string = "var(--color-accent)") {
  return { outlineColor: color };
}