{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tooltip","type":"registry:component","title":"Tooltip","description":"A name and shortcut for an icon. The first one waits; after that they glide from trigger to trigger like one label travelling along the toolbar.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/tooltip.tsx","type":"registry:component","content":"// Hairline UI · tooltip · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { enterStyle, themeVars, useFloating, usePresence, type Align, type Side } from \"./floating\";\nimport { EASE_OUT, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * TOOLTIP: a name for an icon, and its shortcut\n *\n *   first    after a short rest (500ms) it rises off the trigger\n *            out of a blur, from the side it sits on\n *   warm     moving to the next trigger while one is showing:\n *            no wait, and the new tooltip glides over from where\n *            the last one was, so a toolbar reads as one label\n *            travelling along it\n *   leave    a press, Escape or leaving hides it at once\n *\n * Keyboard focus shows it too; touch never does (a tap acts).\n * ───────────────────────────────────────────────────────── */\n\nexport interface TooltipProps {\n  /** The trigger: one element that can take focus (usually an icon button). */\n  children: React.ReactElement<{ \"aria-describedby\"?: string }>;\n  content: React.ReactNode;\n  /** A key or chord shown after the text, e.g. \"⌘K\". */\n  shortcut?: string;\n  side?: Side;\n  align?: Align;\n  /** Rest before the first tooltip shows (ms). */\n  delay?: number;\n  disabled?: boolean;\n}\n\n// Shared across tooltips: where the last one was and when it left, so the next can glide from it.\nlet last: { x: number; y: number; at: number } | null = null;\nconst WARM_MS = 450;\n\nexport function Tooltip({ children, content, shortcut, side = \"top\", align = \"center\", delay = 500, disabled }: TooltipProps) {\n  const reduced = useInteractionReducedMotion();\n  const anchor = React.useRef<HTMLSpanElement>(null);\n  const surface = React.useRef<HTMLDivElement>(null);\n  const [open, setOpen] = React.useState(false);\n  const [warm, setWarm] = React.useState(false);\n  const timer = React.useRef(0);\n  const id = React.useId();\n  const { mounted, shown } = usePresence(open, 140);\n  const place = useFloating({ open: mounted, anchor, floating: surface, side, align, offset: 8 });\n  const [vars, setVars] = React.useState<React.CSSProperties>({});\n\n  const show = (now: boolean) => {\n    if (disabled) return;\n    window.clearTimeout(timer.current);\n    const isWarm = !!last && performance.now() - last.at < WARM_MS;\n    if (now || isWarm) {\n      setWarm(isWarm);\n      setOpen(true);\n    } else timer.current = window.setTimeout(() => {\n      setWarm(false);\n      setOpen(true);\n    }, delay);\n  };\n  const hide = () => {\n    window.clearTimeout(timer.current);\n    setOpen(false);\n  };\n\n  React.useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  // Remember where it was as it leaves; the next tooltip glides from here.\n  React.useEffect(() => {\n    if (!open && place.ready) last = { x: place.x, y: place.y, at: performance.now() };\n    if (open) last = null;\n  }, [open, place.ready, place.x, place.y]);\n\n  React.useLayoutEffect(() => {\n    if (mounted) setVars(themeVars(anchor.current));\n  }, [mounted]);\n\n  // Warm: glide over from the previous tooltip instead of rising out of a blur.\n  const from = React.useRef<{ x: number; y: number } | null>(null);\n  React.useLayoutEffect(() => {\n    if (open && warm) from.current = last ? { x: last.x, y: last.y } : null;\n  }, [open, warm]);\n  React.useLayoutEffect(() => {\n    const el = surface.current;\n    const f = from.current;\n    if (!el || !f || !place.ready || reduced) return;\n    from.current = null;\n    el.animate([{ transform: `translate(${f.x - place.x}px, ${f.y - place.y}px)` }, { transform: \"none\" }], { duration: 300, easing: EASE_OUT });\n  }, [place.ready, place.x, place.y, reduced]);\n\n  const pose = warm ? { opacity: shown ? 1 : 0, transition: reduced ? \"none\" : `opacity ${shown ? 120 : 140}ms ${EASE_OUT}` } : enterStyle(shown, place.side, reduced);\n\n  return (\n    <>\n      <span\n        ref={anchor}\n        className=\"inline-flex\"\n        onPointerEnter={(e) => e.pointerType === \"mouse\" && show(false)}\n        onPointerLeave={hide}\n        onPointerDown={hide}\n        onFocus={(e) => (e.target as HTMLElement).matches(\":focus-visible\") && show(true)}\n        onBlur={hide}\n        onKeyDown={(e) => e.key === \"Escape\" && hide()}\n      >\n        {React.cloneElement(children, { \"aria-describedby\": open ? id : children.props[\"aria-describedby\"] })}\n      </span>\n      {mounted &&\n        createPortal(\n          <div\n            ref={surface}\n            id={id}\n            role=\"tooltip\"\n            className=\"pointer-events-none fixed left-0 top-0 z-[60] flex items-center gap-2 whitespace-nowrap rounded-full bg-foreground px-2.5 py-1 text-[12px] font-medium text-background\"\n            style={{ ...vars, left: place.x, top: place.y, transformOrigin: place.origin, ...pose }}\n          >\n            {content}\n            {shortcut && <kbd className=\"font-mono text-[10.5px] text-background/60\">{shortcut}</kbd>}\n          </div>,\n          document.body,\n        )}\n    </>\n  );\n}\n\nexport default Tooltip;\n"},{"path":"registry/floating.ts","type":"registry:component","content":"// Hairline UI · tooltip · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * FLOATING: what every popover, menu and tooltip shares\n *\n *   place     beside an anchor on a side, lined up start, centre\n *             or end; flips to the other side when it doesn't fit\n *             and shifts to stay `padding` inside the viewport;\n *             follows scroll and resize\n *   presence  mounts on open, shows a frame later (so the entry\n *             transitions run), hides, then unmounts after `exit`\n *   dismiss   Escape and a press outside both close it\n *   theme     the portal carries the anchor's theme tokens, so a\n *             light specimen on a dark page stays light\n * ───────────────────────────────────────────────────────── */\n\nexport type Side = \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type Align = \"start\" | \"center\" | \"end\";\n\nconst TOKENS = [\"--background\", \"--foreground\", \"--popover\", \"--popover-foreground\", \"--border\", \"--accent\", \"--accent-foreground\", \"--primary\", \"--primary-foreground\", \"--muted\", \"--muted-foreground\", \"--ring\", \"--card\"];\n\n/** The anchor's theme tokens as inline styles for something portalled out of its tree. */\nexport function themeVars(anchor: Element | null): React.CSSProperties {\n  if (!anchor) return {};\n  const style = getComputedStyle(anchor);\n  const vars: Record<string, string> = {};\n  for (const t of TOKENS) {\n    const v = style.getPropertyValue(t);\n    if (v) vars[t] = v;\n  }\n  return vars as React.CSSProperties;\n}\n\nconst OPPOSITE: Record<Side, Side> = { top: \"bottom\", bottom: \"top\", left: \"right\", right: \"left\" };\n\nexport interface Placement {\n  x: number;\n  y: number;\n  side: Side;\n  /** Where the surface grows from, for transform-origin. */\n  origin: string;\n  ready: boolean;\n}\n\n/** Positions `floating` (position: fixed) next to `anchor` while `open`. Pass a point instead of an anchor for context menus. */\nexport function useFloating({\n  open,\n  anchor,\n  point,\n  floating,\n  side = \"bottom\",\n  align = \"center\",\n  offset = 8,\n  padding = 8,\n}: {\n  open: boolean;\n  anchor?: React.RefObject<Element | null>;\n  point?: { x: number; y: number } | null;\n  floating: React.RefObject<HTMLElement | null>;\n  side?: Side;\n  align?: Align;\n  offset?: number;\n  padding?: number;\n}): Placement {\n  const [place, setPlace] = React.useState<Placement>({ x: -9999, y: -9999, side, origin: \"center\", ready: false });\n\n  React.useLayoutEffect(() => {\n    if (!open) return;\n    let frame = 0;\n    const measure = () => {\n      const el = floating.current;\n      if (!el) return;\n      const a = point\n        ? { left: point.x, right: point.x, top: point.y, bottom: point.y, width: 0, height: 0 }\n        : anchor?.current?.getBoundingClientRect();\n      if (!a) return;\n      const w = el.offsetWidth;\n      const h = el.offsetHeight;\n      const vw = window.innerWidth;\n      const vh = window.innerHeight;\n      const room: Record<Side, number> = { top: a.top - padding, bottom: vh - a.bottom - padding, left: a.left - padding, right: vw - a.right - padding };\n      const need = side === \"top\" || side === \"bottom\" ? h + offset : w + offset;\n      const s = room[side] < need && room[OPPOSITE[side]] > room[side] ? OPPOSITE[side] : side;\n      let x: number;\n      let y: number;\n      if (s === \"top\" || s === \"bottom\") {\n        y = s === \"bottom\" ? a.bottom + offset : a.top - offset - h;\n        x = align === \"start\" ? a.left : align === \"end\" ? a.right - w : a.left + a.width / 2 - w / 2;\n        if (point) x = a.left;\n      } else {\n        x = s === \"right\" ? a.right + offset : a.left - offset - w;\n        y = align === \"start\" ? a.top : align === \"end\" ? a.bottom - h : a.top + a.height / 2 - h / 2;\n      }\n      x = Math.max(padding, Math.min(vw - padding - w, x));\n      y = Math.max(padding, Math.min(vh - padding - h, y));\n      // Grow from the point on the anchor's edge nearest the surface.\n      const ox = s === \"left\" ? \"100%\" : s === \"right\" ? \"0%\" : `${Math.max(0, Math.min(w, a.left + a.width / 2 - x))}px`;\n      const oy = s === \"top\" ? \"100%\" : s === \"bottom\" ? \"0%\" : `${Math.max(0, Math.min(h, a.top + a.height / 2 - y))}px`;\n      const next = { x: Math.round(x), y: Math.round(y), side: s, origin: `${ox} ${oy}`, ready: true };\n      setPlace((p) => (p.x === next.x && p.y === next.y && p.side === next.side && p.origin === next.origin && p.ready ? p : next));\n    };\n    const schedule = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(measure);\n    };\n    measure();\n    const ro = new ResizeObserver(schedule);\n    if (floating.current) ro.observe(floating.current);\n    window.addEventListener(\"scroll\", schedule, { capture: true, passive: true });\n    window.addEventListener(\"resize\", schedule);\n    return () => {\n      cancelAnimationFrame(frame);\n      ro.disconnect();\n      window.removeEventListener(\"scroll\", schedule, { capture: true });\n      window.removeEventListener(\"resize\", schedule);\n    };\n  }, [open, anchor, point, floating, side, align, offset, padding]);\n\n  return open ? place : { ...place, ready: false };\n}\n\n/** Mount → show next frame → hide → unmount after `exit` ms. */\nexport function usePresence(open: boolean, exit = 160) {\n  const [mounted, setMounted] = React.useState(open);\n  const [shown, setShown] = React.useState(false);\n  if (open && !mounted) setMounted(true);\n  React.useEffect(() => {\n    if (open) {\n      let inner = 0;\n      const outer = requestAnimationFrame(() => {\n        inner = requestAnimationFrame(() => setShown(true));\n      });\n      return () => {\n        cancelAnimationFrame(outer);\n        cancelAnimationFrame(inner);\n      };\n    }\n    const timer = window.setTimeout(() => {\n      setMounted(false);\n      setShown(false);\n    }, exit);\n    return () => window.clearTimeout(timer);\n  }, [open, exit]);\n  return { mounted: mounted || open, shown: open && shown };\n}\n\n/** Escape and presses outside `refs` call onDismiss while `open`. */\nexport function useDismiss(open: boolean, onDismiss: (reason: \"escape\" | \"outside\") => void, refs: React.RefObject<Element | null>[]) {\n  const latest = React.useRef({ onDismiss, refs });\n  React.useEffect(() => {\n    latest.current = { onDismiss, refs };\n  });\n  React.useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\") return;\n      e.stopPropagation();\n      latest.current.onDismiss(\"escape\");\n    };\n    const onDown = (e: PointerEvent) => {\n      const target = e.target as Node;\n      if (latest.current.refs.some((r) => r.current?.contains(target))) return;\n      latest.current.onDismiss(\"outside\");\n    };\n    document.addEventListener(\"keydown\", onKey);\n    document.addEventListener(\"pointerdown\", onDown, true);\n    return () => {\n      document.removeEventListener(\"keydown\", onKey);\n      document.removeEventListener(\"pointerdown\", onDown, true);\n    };\n  }, [open]);\n}\n\n/** The entry pose for a surface on `side`: it comes 6px from the anchor, 0.96 and blurred. */\nexport function enterStyle(shown: boolean, side: Side, reduced: boolean, ease = \"cubic-bezier(0.16,1,0.3,1)\"): React.CSSProperties {\n  const d = 6;\n  const shift = side === \"top\" ? `0 ${d}px` : side === \"bottom\" ? `0 -${d}px` : side === \"left\" ? `${d}px 0` : `-${d}px 0`;\n  return {\n    opacity: shown ? 1 : 0,\n    translate: shown || reduced ? \"0 0\" : shift,\n    scale: shown || reduced ? \"1\" : \"0.96\",\n    filter: shown || reduced ? \"blur(0px)\" : \"blur(4px)\",\n    transition: reduced\n      ? \"opacity 120ms ease-out\"\n      : shown\n        ? `opacity 220ms ${ease}, translate 320ms ${ease}, scale 320ms ${ease}, filter 220ms ${ease}`\n        : `opacity 140ms ${ease}, translate 180ms ${ease}, scale 180ms ${ease}, filter 140ms ${ease}`,\n  };\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/tooltip. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}