{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tooltip","type":"registry:component","title":"Tooltip","description":"Hover or focus tooltip with blur enter/exit and spring spawn.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/tooltip.tsx","type":"registry:component","target":"@components/motion/tooltip.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion, type Variants } from \"motion/react\";\nimport {\n  cloneElement,\n  isValidElement,\n  useId,\n  useRef,\n  useState,\n  type ReactElement,\n  type ReactNode,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"right\" | \"bottom\" | \"left\";\n\nexport interface TooltipProps {\n  content: ReactNode;\n  children: ReactElement;\n  side?: Side;\n  /** Delay before showing (ms). Default 120. */\n  delay?: number;\n  className?: string;\n  /** Classes for the outer wrapper span. Use to fix baseline / fill parent. */\n  wrapperClassName?: string;\n}\n\nconst wrapperClasses: Record<Side, string> = {\n  top: \"bottom-full left-1/2 mb-2 -translate-x-1/2\",\n  bottom: \"top-full left-1/2 mt-2 -translate-x-1/2\",\n  left: \"right-full top-1/2 mr-2 -translate-y-1/2\",\n  right: \"left-full top-1/2 ml-2 -translate-y-1/2\",\n};\n\nconst transformOrigin: Record<Side, string> = {\n  top: \"center bottom\",\n  bottom: \"center top\",\n  left: \"right center\",\n  right: \"left center\",\n};\n\n// Offset is in the direction *away* from the trigger — content originates near\n// the trigger and rises into resting position.\nconst offsetFrom: Record<Side, { x?: number; y?: number }> = {\n  top: { y: 10 },\n  bottom: { y: -10 },\n  left: { x: 10 },\n  right: { x: -10 },\n};\n\nfunction buildVariants(side: Side): Variants {\n  const o = offsetFrom[side];\n  return {\n    initial: {\n      opacity: 0,\n      scale: 0.85,\n      filter: \"blur(10px)\",\n      x: o.x ?? 0,\n      y: o.y ?? 0,\n    },\n    animate: {\n      opacity: 1,\n      scale: 1,\n      filter: \"blur(0px)\",\n      x: 0,\n      y: 0,\n      transition: {\n        type: \"spring\",\n        stiffness: 380,\n        damping: 30,\n        mass: 0.7,\n        opacity: { duration: 0.22, ease: EASE_OUT },\n        filter: { duration: 0.3, ease: EASE_OUT },\n      },\n    },\n    exit: {\n      opacity: 0,\n      scale: 0.92,\n      filter: \"blur(6px)\",\n      x: (o.x ?? 0) * 0.6,\n      y: (o.y ?? 0) * 0.6,\n      transition: { duration: 0.14, ease: EASE_OUT },\n    },\n  };\n}\n\nconst REDUCED_VARIANTS: Variants = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } },\n  exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } },\n};\n\n// Once any tooltip has just closed, neighbouring tooltips open without the\n// initial delay — moving along a toolbar feels instant after the first one.\nconst WARM_WINDOW_MS = 300;\nlet lastHiddenAt = 0;\n\nexport function Tooltip({ content, children, side = \"top\", delay = 120, className, wrapperClassName }: TooltipProps) {\n  const [open, setOpen] = useState(false);\n  const id = useId();\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n\n  const show = () => {\n    if (!canHover) return;\n    if (timer.current) clearTimeout(timer.current);\n    const warm = Date.now() - lastHiddenAt < WARM_WINDOW_MS;\n    timer.current = setTimeout(() => setOpen(true), warm ? 0 : delay);\n  };\n  const hide = () => {\n    if (timer.current) {\n      clearTimeout(timer.current);\n      timer.current = null;\n    }\n    if (open) lastHiddenAt = Date.now();\n    setOpen(false);\n  };\n\n  if (!isValidElement(children)) return children;\n\n  const trigger = cloneElement(children as ReactElement<Record<string, unknown>>, {\n    onMouseEnter: show,\n    onMouseLeave: hide,\n    onFocus: show,\n    onBlur: hide,\n    \"aria-describedby\": id,\n  });\n\n  const variants = reduce ? REDUCED_VARIANTS : buildVariants(side);\n\n  return (\n    <span className={cn(\"relative inline-flex align-middle\", wrapperClassName)}>\n      {trigger}\n      <AnimatePresence mode=\"wait\">\n        {open ? (\n          <span className={cn(\"pointer-events-none absolute z-50\", wrapperClasses[side])}>\n            <motion.span\n              id={id}\n              role=\"tooltip\"\n              variants={variants}\n              initial=\"initial\"\n              animate=\"animate\"\n              exit=\"exit\"\n              style={{\n                transformOrigin: transformOrigin[side],\n                willChange: \"transform, opacity, filter\",\n              }}\n              className={cn(\n                \"block whitespace-nowrap rounded-lg border border-border bg-popover/85 px-2.5 py-1 text-xs font-medium text-popover-foreground shadow-2xl backdrop-blur-xl\",\n                className,\n              )}\n            >\n              {content}\n            </motion.span>\n          </span>\n        ) : null}\n      </AnimatePresence>\n    </span>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}