# Tooltip

Springs out of its trigger. After the first one opens, neighbours open instantly, so scanning a toolbar feels quick.

**Category:** Overlays  
**Exports:** `Tooltip`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/tooltip.json
```
The file lands at `components/ui/tooltip.tsx`; import from `@/components/ui/tooltip`.

## API — Tooltip
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `content` | `ReactNode` | — |  |
| `side` | `"top" \| "bottom" \| "left" \| "right"` | `"top"` |  |
| `delay` | `number` | `350` |  |
| `children` | `ReactElement` | — | A single focusable element. |

## Example — Tooltip

```tsx
import { Bold, Italic, Underline, Copy, Download } from "lucide-react";
import { Tooltip } from "@/components/ui/tooltip";

const tools = [
  { label: "Bold", key: "⌘B", icon: <Bold /> },
  { label: "Italic", key: "⌘I", icon: <Italic /> },
  { label: "Underline", key: "⌘U", icon: <Underline /> },
  { label: "Copy", key: "⌘C", icon: <Copy /> },
  { label: "Download", key: "⌘S", icon: <Download /> },
];

export default function TooltipDemo({ side = "top", delay = 350 }: { side?: "top" | "bottom" | "left" | "right"; delay?: number }) {
  return (
    <div className="flex items-center gap-1 rounded-xl border bg-background p-1">
      {tools.map((t) => (
        <Tooltip
          key={t.label}
          side={side}
          delay={delay}
          content={
            <span className="flex items-center gap-2">
              {t.label} <kbd className="font-mono opacity-60">{t.key}</kbd>
            </span>
          }
        >
          <button
            type="button"
            aria-label={t.label}
            className="grid size-10 cursor-pointer place-items-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring [&_svg]:size-4"
          >
            {t.icon}
          </button>
        </Tooltip>
      ))}
    </div>
  );
}
```

## Source — components/ui/tooltip.tsx

```tsx
"use client";

import * as React from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

type Side = "top" | "bottom" | "left" | "right";

// When one tooltip closes and another opens soon after, skip the delay —
// scanning a toolbar should feel instant after the first hover.
let lastClosedAt = 0;
const WARM_WINDOW = 400;

const offsets: Record<Side, { x: number; y: number; origin: string }> = {
  top: { x: 0, y: 6, origin: "bottom" },
  bottom: { x: 0, y: -6, origin: "top" },
  left: { x: 6, y: 0, origin: "right" },
  right: { x: -6, y: 0, origin: "left" },
};

export type TooltipProps = {
  content: React.ReactNode;
  side?: Side;
  /** Milliseconds before the tooltip opens on hover. */
  delay?: number;
  className?: string;
  children: React.ReactElement<Record<string, unknown>>;
};

export function Tooltip({ content, side = "top", delay = 350, className, children }: TooltipProps) {
  const id = React.useId();
  const reduce = useReducedMotion();
  const wrapRef = React.useRef<HTMLSpanElement>(null);
  const timer = React.useRef<number | undefined>(undefined);
  const [open, setOpen] = React.useState(false);
  const [pos, setPos] = React.useState({ top: 0, left: 0 });

  const place = () => {
    const el = wrapRef.current?.firstElementChild ?? wrapRef.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    const gap = 8;
    const map: Record<Side, { top: number; left: number }> = {
      top: { top: r.top - gap, left: r.left + r.width / 2 },
      bottom: { top: r.bottom + gap, left: r.left + r.width / 2 },
      left: { top: r.top + r.height / 2, left: r.left - gap },
      right: { top: r.top + r.height / 2, left: r.right + gap },
    };
    setPos(map[side]);
  };

  const show = (immediate = false) => {
    window.clearTimeout(timer.current);
    const warm = Date.now() - lastClosedAt < WARM_WINDOW;
    const run = () => {
      place();
      setOpen(true);
    };
    if (immediate || warm) run();
    else timer.current = window.setTimeout(run, delay);
  };

  const hide = () => {
    window.clearTimeout(timer.current);
    setOpen((was) => {
      if (was) lastClosedAt = Date.now();
      return false;
    });
  };

  React.useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && hide();
    window.addEventListener("keydown", onKey);
    window.addEventListener("scroll", hide, true);
    return () => {
      window.removeEventListener("keydown", onKey);
      window.removeEventListener("scroll", hide, true);
    };
  }, [open]);

  React.useEffect(() => () => window.clearTimeout(timer.current), []);

  const translate: Record<Side, string> = {
    top: "-50%, -100%",
    bottom: "-50%, 0",
    left: "-100%, -50%",
    right: "0, -50%",
  };
  const o = offsets[side];

  return (
    <>
      <span
        ref={wrapRef}
        className="inline-flex"
        onPointerEnter={(e) => e.pointerType === "mouse" && show()}
        onPointerLeave={hide}
        onPointerDown={hide}
        onFocus={(e) => (e.target as HTMLElement).matches(":focus-visible") && show(true)}
        onBlur={hide}
      >
        {React.isValidElement(children)
          ? React.cloneElement(children, { "aria-describedby": open ? id : undefined })
          : children}
      </span>
      {typeof document !== "undefined" &&
        createPortal(
          <AnimatePresence>
            {open && (
              <div
                className="pointer-events-none fixed z-50"
                style={{ top: pos.top, left: pos.left, transform: `translate(${translate[side]})` }}
              >
                <motion.div
                  id={id}
                  role="tooltip"
                  initial={{ opacity: 0, scale: reduce ? 1 : 0.92, x: reduce ? 0 : o.x, y: reduce ? 0 : o.y, filter: "blur(3px)" }}
                  animate={{ opacity: 1, scale: 1, x: 0, y: 0, filter: "blur(0px)" }}
                  exit={{ opacity: 0, scale: reduce ? 1 : 0.96, filter: "blur(2px)", transition: { duration: 0.1 } }}
                  transition={{ type: "spring", stiffness: 600, damping: 32 }}
                  style={{ transformOrigin: o.origin }}
                  className={cn(
                    "max-w-64 rounded-lg bg-foreground px-2.5 py-1.5 text-xs font-medium text-background shadow-lg",
                    className,
                  )}
                >
                  {content}
                </motion.div>
              </div>
            )}
          </AnimatePresence>,
          document.body,
        )}
    </>
  );
}
```