{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tooltip","type":"registry:ui","title":"Tooltip","description":"Hover and focus label rendered in a portal: it scales in from its trigger on a soft spring, flips to the side with room, carries a title, description, arrow and shortcut, and hands off instantly between neighbours.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion"],"registryDependencies":[],"files":[{"path":"registry/geturui/ui/tooltip.tsx","type":"registry:ui","target":"components/ui/tooltip.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type TooltipSide = \"top\" | \"right\" | \"bottom\" | \"left\";\nexport type TooltipAlign = \"start\" | \"center\" | \"end\";\n\n/** Shared timing for a group of tooltips: once one has been open, neighbours open instantly. Holds no render state. */\nclass TooltipGroup {\n  delay?: number;\n  closeDelay?: number;\n  /** Window after a tooltip closes during which the next one skips its delay, ms. */\n  skipDelay = 300;\n  private lastClosedAt = 0;\n  private active: (() => void) | null = null;\n\n  configure(delay: number | undefined, closeDelay: number | undefined, skipDelay: number) {\n    this.delay = delay;\n    this.closeDelay = closeDelay;\n    this.skipDelay = skipDelay;\n  }\n  /** True while another tooltip is open or one closed moments ago. */\n  isWarm() {\n    return this.active !== null || Date.now() - this.lastClosedAt < this.skipDelay;\n  }\n  /** Close whichever other tooltip is open, then mark `close` as the open one. */\n  activate(close: () => void) {\n    if (this.active && this.active !== close) this.active();\n    this.active = close;\n  }\n  release(close: () => void) {\n    if (this.active === close) this.active = null;\n    this.lastClosedAt = Date.now();\n  }\n}\n\n// Fallback group for tooltips rendered without a provider.\nconst defaultGroup = new TooltipGroup();\nconst TooltipGroupContext = React.createContext<TooltipGroup | null>(null);\n\nexport interface TooltipProviderProps {\n  /** Default open delay for tooltips inside, ms. */\n  delay?: number;\n  /** Default close delay for tooltips inside, ms. */\n  closeDelay?: number;\n  /** After one tooltip closes, others open instantly for this long, ms. @default 300 */\n  skipDelay?: number;\n  children: React.ReactNode;\n}\n\n/** Optional: groups tooltips (e.g. one toolbar) so moving between triggers hands off instantly, and sets shared delays. */\nexport function TooltipProvider({ delay, closeDelay, skipDelay = 300, children }: TooltipProviderProps) {\n  const [group] = React.useState(() => new TooltipGroup());\n  React.useEffect(() => {\n    group.configure(delay, closeDelay, skipDelay);\n  }, [group, delay, closeDelay, skipDelay]);\n  return <TooltipGroupContext.Provider value={group}>{children}</TooltipGroupContext.Provider>;\n}\n\nexport interface TooltipProps {\n  /** Tooltip label. Alias: `title`. */\n  content?: React.ReactNode;\n  /** Bold first line; same as `content`. */\n  title?: React.ReactNode;\n  /** Supporting text under the title; widens the bubble and wraps. */\n  description?: React.ReactNode;\n  /** Keyboard shortcut shown beside the label. */\n  kbd?: string;\n  /** Preferred side; flips to the opposite side when there is no room. */\n  side?: TooltipSide;\n  /** Alignment along the side. */\n  align?: TooltipAlign;\n  /** Gap between trigger and bubble, px. */\n  sideOffset?: number;\n  /** Draw a small arrow pointing at the trigger. */\n  arrow?: boolean;\n  /** Delay before opening, ms. */\n  delay?: number;\n  /** Delay before closing after the pointer leaves, ms. Gives the pointer time to reach the bubble. */\n  closeDelay?: number;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Never open. */\n  disabled?: boolean;\n  children: React.ReactElement;\n  className?: string;\n}\n\ntype Position = { x: number; y: number; side: TooltipSide; arrow: number };\n\nconst EDGE = 8;\nconst subscribe = () => () => {};\n\nfunction useIsClient() {\n  return React.useSyncExternalStore(\n    subscribe,\n    () => true,\n    () => false,\n  );\n}\n\nfunction place(trigger: DOMRect, w: number, h: number, side: TooltipSide, align: TooltipAlign, offset: number): Position {\n  const vw = document.documentElement.clientWidth;\n  const vh = window.innerHeight;\n  const room: Record<TooltipSide, number> = {\n    top: trigger.top - offset - EDGE,\n    bottom: vh - trigger.bottom - offset - EDGE,\n    left: trigger.left - offset - EDGE,\n    right: vw - trigger.right - offset - EDGE,\n  };\n  const opposite: Record<TooltipSide, TooltipSide> = { top: \"bottom\", bottom: \"top\", left: \"right\", right: \"left\" };\n  const need = (s: TooltipSide) => (s === \"top\" || s === \"bottom\" ? h : w);\n  let s = side;\n  if (room[s] < need(s) && room[opposite[s]] > room[s]) s = opposite[s];\n\n  const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), Math.max(min, max));\n  if (s === \"top\" || s === \"bottom\") {\n    const raw = align === \"start\" ? trigger.left : align === \"end\" ? trigger.right - w : trigger.left + trigger.width / 2 - w / 2;\n    const x = clamp(raw, EDGE, vw - EDGE - w);\n    const y = s === \"top\" ? trigger.top - offset - h : trigger.bottom + offset;\n    const arrow = clamp(trigger.left + trigger.width / 2 - x, 10, w - 10);\n    return { x: Math.round(x), y: Math.round(y), side: s, arrow: Math.round(arrow) };\n  }\n  const raw = align === \"start\" ? trigger.top : align === \"end\" ? trigger.bottom - h : trigger.top + trigger.height / 2 - h / 2;\n  const y = clamp(raw, EDGE, vh - EDGE - h);\n  const x = s === \"left\" ? trigger.left - offset - w : trigger.right + offset;\n  const arrow = clamp(trigger.top + trigger.height / 2 - y, 10, h - 10);\n  return { x: Math.round(x), y: Math.round(y), side: s, arrow: Math.round(arrow) };\n}\n\nconst enterFrom: Record<TooltipSide, { x: number; y: number }> = {\n  top: { x: 0, y: 4 },\n  bottom: { x: 0, y: -4 },\n  left: { x: 4, y: 0 },\n  right: { x: -4, y: 0 },\n};\nconst origin: Record<TooltipSide, string> = { top: \"50% 100%\", bottom: \"50% 0%\", left: \"100% 50%\", right: \"0% 50%\" };\n\n/**\n * Hover/focus label rendered in a portal so it is never clipped. It scales in from its trigger on a soft spring,\n * flips to the side with room, supports a title + description, an arrow and a shortcut, and hands off instantly\n * between neighbouring triggers.\n */\nexport function Tooltip({\n  content,\n  title,\n  description,\n  kbd,\n  side = \"top\",\n  align = \"center\",\n  sideOffset = 8,\n  arrow = false,\n  delay,\n  closeDelay,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  disabled,\n  children,\n  className,\n}: TooltipProps) {\n  const group = React.useContext(TooltipGroupContext) ?? defaultGroup;\n  const reduce = useReducedMotion();\n  const isClient = useIsClient();\n  const id = React.useId();\n  const [inner, setInner] = React.useState(defaultOpen);\n  const open = !disabled && (openProp ?? inner);\n  const [instant, setInstant] = React.useState(false);\n  const [pos, setPos] = React.useState<Position | null>(null);\n  const triggerRef = React.useRef<HTMLSpanElement>(null);\n  const bubbleRef = React.useRef<HTMLDivElement>(null);\n  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n  const longPress = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  // Forget the last measurement whenever it closes, so the next open measures fresh.\n  const [wasOpen, setWasOpen] = React.useState(open);\n  if (wasOpen !== open) {\n    setWasOpen(open);\n    if (!open) setPos(null);\n  }\n\n  const clear = () => {\n    if (timer.current) clearTimeout(timer.current);\n    timer.current = null;\n  };\n\n  const setOpen = React.useCallback(\n    (next: boolean) => {\n      if (openProp === undefined) setInner(next);\n      onOpenChange?.(next);\n    },\n    [openProp, onOpenChange],\n  );\n\n  const closeNow = React.useCallback(() => {\n    if (timer.current) clearTimeout(timer.current);\n    timer.current = null;\n    setOpen(false);\n  }, [setOpen]);\n\n  const show = (immediate = false) => {\n    if (disabled) return;\n    clear();\n    const warm = group.isWarm();\n    const wait = immediate || warm ? 0 : (delay ?? group.delay ?? 120);\n    const run = () => {\n      setInstant(warm);\n      setOpen(true);\n    };\n    if (wait === 0) run();\n    else timer.current = setTimeout(run, wait);\n  };\n\n  const hide = (afterMs?: number) => {\n    clear();\n    const wait = afterMs ?? 0;\n    if (wait === 0) setOpen(false);\n    else timer.current = setTimeout(() => setOpen(false), wait);\n  };\n\n  // Register as the group's open tooltip; stamp the close time so neighbours skip their delay.\n  React.useEffect(() => {\n    if (!open) return;\n    group.activate(closeNow);\n    return () => group.release(closeNow);\n  }, [open, group, closeNow]);\n\n  // Escape closes from anywhere while open (WCAG 1.4.13 dismissible).\n  React.useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") closeNow();\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [open, closeNow]);\n\n  // Measure and place; re-place on resize, scroll and content changes.\n  React.useEffect(() => {\n    const bubble = bubbleRef.current;\n    const trigger = triggerRef.current;\n    if (!open || !bubble || !trigger) return;\n    let frame = 0;\n    const update = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(() => {\n        setPos(place(trigger.getBoundingClientRect(), bubble.offsetWidth, bubble.offsetHeight, side, align, sideOffset));\n      });\n    };\n    const ro = new ResizeObserver(update);\n    ro.observe(bubble);\n    ro.observe(trigger);\n    window.addEventListener(\"scroll\", update, true);\n    window.addEventListener(\"resize\", update);\n    return () => {\n      cancelAnimationFrame(frame);\n      ro.disconnect();\n      window.removeEventListener(\"scroll\", update, true);\n      window.removeEventListener(\"resize\", update);\n    };\n  }, [open, side, align, sideOffset, isClient]);\n\n  React.useEffect(\n    () => () => {\n      if (timer.current) clearTimeout(timer.current);\n      if (longPress.current) clearTimeout(longPress.current);\n    },\n    [],\n  );\n\n  const label = content ?? title;\n  const rich = description != null;\n  const childProps = children.props as { \"aria-describedby\"?: string };\n  const describedBy = [childProps[\"aria-describedby\"], open ? id : undefined].filter(Boolean).join(\" \") || undefined;\n  const s = pos?.side ?? side;\n  const from = reduce ? { x: 0, y: 0 } : enterFrom[s];\n  const leaveDelay = closeDelay ?? group.closeDelay ?? 80;\n\n  return (\n    <span\n      ref={triggerRef}\n      className=\"relative inline-flex\"\n      onPointerEnter={(e) => e.pointerType !== \"touch\" && show()}\n      onPointerLeave={(e) => e.pointerType !== \"touch\" && hide(leaveDelay)}\n      onPointerDown={(e) => {\n        if (e.pointerType === \"touch\") {\n          // Long-press opens on touch; a quick tap passes straight through to the trigger.\n          if (longPress.current) clearTimeout(longPress.current);\n          longPress.current = setTimeout(() => {\n            show(true);\n            longPress.current = setTimeout(() => setOpen(false), 1600);\n          }, 450);\n          return;\n        }\n        // Pressing the trigger dismisses its label so it doesn't cover the result.\n        closeNow();\n      }}\n      onPointerUp={(e) => {\n        if (e.pointerType === \"touch\" && longPress.current && !open) clearTimeout(longPress.current);\n      }}\n      onPointerCancel={() => {\n        if (longPress.current) clearTimeout(longPress.current);\n      }}\n      onFocus={(e) => {\n        // Open for keyboard focus only, not focus that follows a mouse press.\n        let visible = true;\n        try {\n          visible = (e.target as HTMLElement).matches(\":focus-visible\");\n        } catch {}\n        if (visible) show();\n      }}\n      onBlur={() => hide()}\n    >\n      {React.cloneElement(children as React.ReactElement<{ \"aria-describedby\"?: string }>, { \"aria-describedby\": describedBy })}\n      {isClient &&\n        createPortal(\n          <AnimatePresence>\n            {open && label != null && (\n              <div\n                key=\"tip\"\n                className=\"pointer-events-none fixed left-0 top-0 z-[110]\"\n                style={{ transform: pos ? `translate(${pos.x}px, ${pos.y}px)` : undefined, visibility: pos ? \"visible\" : \"hidden\" }}\n              >\n                <motion.div\n                  ref={bubbleRef}\n                  id={id}\n                  role=\"tooltip\"\n                  initial={instant ? { opacity: 0 } : { opacity: 0, scale: reduce ? 1 : 0.94, ...from }}\n                  animate={pos ? { opacity: 1, scale: 1, x: 0, y: 0 } : undefined}\n                  exit={{ opacity: 0, scale: reduce ? 1 : 0.96, transition: { duration: 0.1 } }}\n                  transition={instant ? { duration: 0.08 } : { type: \"spring\", stiffness: 500, damping: 30 }}\n                  style={{ transformOrigin: origin[s] }}\n                  onPointerEnter={() => clear()}\n                  onPointerLeave={() => hide(leaveDelay)}\n                  className={cn(\n                    \"pointer-events-auto relative rounded-[10px] border border-border bg-surface-raised text-xs font-medium text-ink shadow-md\",\n                    rich ? \"grid w-max max-w-[260px] gap-1 px-3 py-2.5 text-left\" : \"inline-flex items-center gap-2 whitespace-nowrap px-2.5 py-1.5\",\n                    className,\n                  )}\n                >\n                  {rich ? (\n                    <>\n                      <span className=\"flex items-center justify-between gap-3 font-semibold leading-4 text-ink\">\n                        {label}\n                        {kbd && <Kbd>{kbd}</Kbd>}\n                      </span>\n                      <span className=\"text-xs font-normal leading-[18px] text-ink-muted\">{description}</span>\n                    </>\n                  ) : (\n                    <>\n                      {label}\n                      {kbd && <Kbd>{kbd}</Kbd>}\n                    </>\n                  )}\n                  {arrow && (\n                    <span\n                      aria-hidden\n                      className={cn(\n                        \"absolute size-2.5 rotate-45 rounded-[2px] border-border bg-surface-raised\",\n                        s === \"top\" && \"-bottom-[5px] border-b border-r\",\n                        s === \"bottom\" && \"-top-[5px] border-l border-t\",\n                        s === \"left\" && \"-right-[5px] border-r border-t\",\n                        s === \"right\" && \"-left-[5px] border-b border-l\",\n                      )}\n                      style={s === \"top\" || s === \"bottom\" ? { left: (pos?.arrow ?? 0) - 5 } : { top: (pos?.arrow ?? 0) - 5 }}\n                    />\n                  )}\n                </motion.div>\n              </div>\n            )}\n          </AnimatePresence>,\n          document.body,\n        )}\n    </span>\n  );\n}\n\nfunction Kbd({ children }: { children: React.ReactNode }) {\n  return (\n    <kbd className=\"inline-flex h-5 min-w-5 items-center justify-center rounded-md border border-border bg-surface px-1 font-mono text-[11px] font-normal text-ink-muted\">\n      {children}\n    </kbd>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["components","tooltip","hint","kbd","shortcut","toolbar","popover","portal"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/tooltip"}}