|Design System

Core Component

Tooltip

Contextual hint that appears on hover or focus with a skip-delay pattern — instant on subsequent triggers.

Preview

Source

Full component implementation using the design system tokens.

tsx
"use client";

import { useState, useRef, useCallback, useEffect } from "react";

let lastCloseTime = 0;

export function DSTooltip({
  content,
  children,
  side = "top",
  delayMs = 200,
}: {
  content: string;
  children: React.ReactNode;
  side?: "top" | "bottom" | "left" | "right";
  delayMs?: number;
}) {
  const [visible, setVisible] = useState(false);
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const triggerRef = useRef<HTMLDivElement>(null);
  const tooltipRef = useRef<HTMLDivElement>(null);
  const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
  const skipAnimation = useRef(false);

  const updatePosition = useCallback(() => {
    const trigger = triggerRef.current;
    const tooltip = tooltipRef.current;
    if (!trigger || !tooltip) return;
    const rect = trigger.getBoundingClientRect();
    const tipRect = tooltip.getBoundingClientRect();
    const gap = 8;
    let x = 0, y = 0;
    switch (side) {
      case "top": x = rect.left + rect.width / 2 - tipRect.width / 2; y = rect.top - tipRect.height - gap; break;
      case "bottom": x = rect.left + rect.width / 2 - tipRect.width / 2; y = rect.bottom + gap; break;
      case "left": x = rect.left - tipRect.width - gap; y = rect.top + rect.height / 2 - tipRect.height / 2; break;
      case "right": x = rect.right + gap; y = rect.top + rect.height / 2 - tipRect.height / 2; break;
    }
    setPosition({ x, y });
  }, [side]);

  const show = useCallback(() => {
    clearTimeout(timeoutRef.current);
    const timeSinceClose = Date.now() - lastCloseTime;
    skipAnimation.current = timeSinceClose < 300;
    timeoutRef.current = setTimeout(() => setVisible(true), timeSinceClose < 300 ? 0 : delayMs);
  }, [delayMs]);

  const hide = useCallback(() => {
    clearTimeout(timeoutRef.current);
    setVisible(false);
    lastCloseTime = Date.now();
  }, []);

  useEffect(() => { if (visible) updatePosition(); }, [visible, updatePosition]);
  useEffect(() => () => clearTimeout(timeoutRef.current), []);

  const tooltipId = useRef(`tooltip-${Math.random().toString(36).slice(2, 9)}`).current;

  return (
    <>
      <div ref={triggerRef} onMouseEnter={show} onMouseLeave={hide} onFocus={show} onBlur={hide}
        aria-describedby={visible ? tooltipId : undefined} className="inline-block">
        {children}
      </div>
      <div ref={tooltipRef} id={tooltipId} role="tooltip" className="fixed z-[60] pointer-events-none"
        style={{ left: position.x, top: position.y, opacity: visible ? 1 : 0,
          transform: visible ? "scale(1)" : "scale(0.97)",
          transition: skipAnimation.current ? "none" : "opacity 125ms var(--ease-out), transform 125ms var(--ease-out)" }}>
        <div className="px-3 py-1.5 text-xs font-medium text-background bg-foreground rounded-lg shadow-lg whitespace-nowrap">
          {content}
        </div>
      </div>
    </>
  );
}

Props

All available props with types and defaults.

PropTypeDescription
content*stringTooltip text
children*ReactNodeTrigger element
side'top' | 'bottom' | 'left' | 'right'Preferred tooltip position
delayMsnumberInitial show delay in ms

Variants

Top (default)

Tooltip above the trigger element.

tsx
<DSTooltip content="Send message">
  <DSButton>Send</DSButton>
</DSTooltip>

Bottom

Tooltip below the trigger element.

tsx
<DSTooltip content="More options" side="bottom">
  <DSButton variant="ghost">⋯</DSButton>
</DSTooltip>

Prompt Guide

Prompt Guide — Tooltip

Use for

  • Icon-only buttons that need labels
  • Truncated text that needs full display
  • Keyboard shortcut hints
  • Brief parameter descriptions

Don't use for

  • Interactive content — use a popover instead
  • Error messages — use inline validation
  • Long descriptions — keep to one line

AI Context

Implements the skip-delay pattern: first tooltip shows after 200ms, subsequent tooltips appear instantly if triggered within 300ms of the last close. This matches the macOS/Vercel tooltip UX. Animation is 125ms scale(0.97→1) + opacity.