{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tooltip",
  "title": "Tooltip",
  "description": "Floating tooltip with configurable placement, a follow-cursor mode, and rich content support.",
  "dependencies": [
    "@base-ui/react"
  ],
  "registryDependencies": [
    "https://www.deltacomponents.dev/r/utils.json",
    "https://www.deltacomponents.dev/r/motion.json",
    "https://www.deltacomponents.dev/r/font-weight.json"
  ],
  "files": [
    {
      "path": "registry/ui/tooltip.tsx",
      "content": "\"use client\";\n\nimport {\n  createContext,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { Tooltip as TooltipPrimitive } from \"@base-ui/react/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport { fontWeights } from \"@/lib/font-weight\";\n\n// ---------------------------------------------------------------------------\n// Portal container context\n// ---------------------------------------------------------------------------\n\nconst TooltipPortalContainerContext = createContext<HTMLElement | null>(null);\n\nfunction TooltipPortalContainer({\n  value,\n  children,\n}: {\n  value: HTMLElement | null;\n  children: ReactNode;\n}) {\n  return (\n    <TooltipPortalContainerContext.Provider value={value}>\n      {children}\n    </TooltipPortalContainerContext.Provider>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Provider\n// ---------------------------------------------------------------------------\n\n// What the reader waits is the delay PLUS the entrance, and only the sum is\n// perceptible to them — a 200ms delay in front of a 160ms fade is a 360ms\n// tooltip. 200ms is the budget from hover to a tooltip that is fully there, so\n// the entrance (`--motion-moderate`, 160ms, on the popup below) comes out of\n// it and this is the remainder. A number, not a `calc()` on the token: it is\n// handed to Base UI as a JS timer, which cannot read a CSS custom property.\n// Retune it if that tier ever moves.\nconst DEFAULT_DELAY = 40;\n\n// Tracks whether an app-level <TooltipProvider> is above us. Each Tooltip\n// only wraps itself in a local primitive Provider when there isn't one —\n// a per-instance Provider would defeat cross-tooltip skip-delay grouping\n// (moving between adjacent tooltips would re-wait the full delay).\nconst TooltipGroupContext = createContext(false);\n\ninterface TooltipProviderProps {\n  children: ReactNode;\n  /** Hover delay before tooltips open, in ms. Defaults to 40 — the 200ms a\n   *  tooltip has to become fully visible, less the 160ms entrance. */\n  delayDuration?: number;\n  /** After a tooltip closes, adjacent tooltips opened within this window\n   *  skip the hover delay, in ms. Defaults to 300. */\n  skipDelayDuration?: number;\n}\n\n/** Groups descendant Tooltips so that once one opens, moving to an adjacent\n *  trigger shows its tooltip instantly instead of re-waiting the full delay.\n *  Wrap once at the app (or section) level; bare Tooltips still work without\n *  it via a per-instance fallback. */\nfunction TooltipProvider({\n  children,\n  delayDuration = DEFAULT_DELAY,\n  skipDelayDuration = 300,\n}: TooltipProviderProps) {\n  return (\n    <TooltipGroupContext.Provider value={true}>\n      <TooltipPrimitive.Provider\n        delay={delayDuration}\n        timeout={skipDelayDuration}\n      >\n        {children}\n      </TooltipPrimitive.Provider>\n    </TooltipGroupContext.Provider>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\ntype TooltipSide = \"top\" | \"right\" | \"bottom\" | \"left\";\n\ninterface TooltipProps {\n  content: ReactNode;\n  children: React.ReactElement;\n  side?: TooltipSide;\n  sideOffset?: number;\n  /** Hover delay before this tooltip opens, in ms. Defaults to 40, or to the\n   *  ambient TooltipProvider's delayDuration when one is present. The entrance\n   *  runs after it, so the tooltip is fully visible 160ms later. */\n  delayDuration?: number;\n  className?: string;\n  /** Extra classes for the portalled positioner element — pass a z utility\n   *  here to lift the whole tooltip above other fixed layers (default z-50). */\n  contentClassName?: string;\n  /** When true, forces the tooltip open. When false, forces it closed. When undefined, uses default hover/focus behavior. */\n  forceOpen?: boolean;\n  /** Follow the cursor along one axis while hovering the trigger — for tall\n   *  or wide triggers (the Sidebar rail) where a centered tooltip sits far\n   *  from the pointer. The other axis stays anchored by `side`. */\n  followCursor?: \"x\" | \"y\";\n  /** Called when the tooltip's internal open state changes (before forceOpen is applied). */\n  onOpenChange?: (open: boolean) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Animation helpers\n// ---------------------------------------------------------------------------\n\n// The tooltip slides 4px from the side it is anchored to, and back out the\n// same way on exit.\nfunction getSlideClasses(side: TooltipSide) {\n  switch (side) {\n    case \"top\":\n      return \"data-[starting-style]:translate-y-1 data-[ending-style]:translate-y-1\";\n    case \"bottom\":\n      return \"data-[starting-style]:-translate-y-1 data-[ending-style]:-translate-y-1\";\n    case \"left\":\n      return \"data-[starting-style]:translate-x-1 data-[ending-style]:translate-x-1\";\n    case \"right\":\n      return \"data-[starting-style]:-translate-x-1 data-[ending-style]:-translate-x-1\";\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Tooltip\n// ---------------------------------------------------------------------------\n\nfunction Tooltip({\n  content,\n  children,\n  side = \"top\",\n  sideOffset = 8,\n  delayDuration,\n  className,\n  contentClassName,\n  forceOpen,\n  onOpenChange: onOpenChangeProp,\n  followCursor,\n}: TooltipProps) {\n  const [internalOpen, setInternalOpen] = useState(false);\n  const open = forceOpen !== undefined ? forceOpen : internalOpen;\n  const portalContainer = useContext(TooltipPortalContainerContext);\n  const hasAmbientProvider = useContext(TooltipGroupContext);\n\n  // Cursor-follow offset from the trigger's center, written straight to a CSS\n  // custom property on the popup element so per-move updates skip React\n  // re-renders entirely.\n  const popupRef = useRef<HTMLDivElement>(null);\n  // A force-opened follow-cursor tooltip has no cursor to follow — it rests\n  // centered on the trigger until a real pointer takes over.\n  useEffect(() => {\n    if (forceOpen && followCursor) {\n      popupRef.current?.style.setProperty(\"--tooltip-follow\", \"0px\");\n    }\n  }, [forceOpen, followCursor]);\n  const handleFollowMove = (event: React.PointerEvent) => {\n    if (!followCursor) return;\n    const rect = event.currentTarget.getBoundingClientRect();\n    const offset =\n      followCursor === \"y\"\n        ? event.clientY - (rect.top + rect.height / 2)\n        : event.clientX - (rect.left + rect.width / 2);\n    popupRef.current?.style.setProperty(\"--tooltip-follow\", `${offset}px`);\n  };\n\n  const tooltip = (\n    <TooltipPrimitive.Root\n      open={open}\n      onOpenChange={(v) => {\n        setInternalOpen(v);\n        onOpenChangeProp?.(v);\n      }}\n    >\n      {/* An explicit delayDuration overrides the ambient provider's delay;\n          left undefined, the trigger inherits it from the provider. */}\n      <TooltipPrimitive.Trigger\n        render={children}\n        delay={delayDuration}\n        onPointerMove={followCursor ? handleFollowMove : undefined}\n      />\n      <TooltipPrimitive.Portal container={portalContainer ?? undefined}>\n        <TooltipPrimitive.Positioner\n          side={side}\n          sideOffset={sideOffset}\n          className={cn(\"z-50\", contentClassName)}\n        >\n          <TooltipPrimitive.Popup\n            ref={popupRef}\n            className={cn(\n              // Trim recenters the label; the padding bump only applies\n              // where text-box is supported, keeping the same overall\n              // height (~26px) as untrimmed browsers.\n              \"bg-foreground text-background text-[12px] px-2 py-1\",\n              \"[text-box:trim-both_cap_alphabetic] supports-[text-box:trim-both]:py-2\",\n              \"rounded-[var(--radius-bg,var(--radius,0.5rem))]\",\n              // Zooms out of the trigger rather than its own centre — Base UI\n              // resolves the anchor point onto `--transform-origin`.\n              \"origin-(--transform-origin)\",\n              // Fade + a 4px slide off the anchored edge + a 95% zoom, on the\n              // moderate tier and the site's one easing, exit a tier quicker.\n              //\n              // Moderate rather than fast: 80ms lands under the ~100ms mark\n              // where a change stops reading as movement at all, so the slide\n              // and zoom were running but arriving invisibly. 160ms is also\n              // the ceiling — an entrance that fires on every hover has to\n              // stay around 150ms or the attention cost repeats all day.\n              //\n              // `translate` and `scale` are named individually because\n              // Tailwind v4 compiles them to standalone properties: the\n              // long-standing `transition-[opacity,transform]` here animated\n              // neither, so the slide snapped and only the opacity moved.\n              //\n              // followCursor writes `translate` inline on every pointer move\n              // and has to track the pointer 1:1, so it sits out the slide.\n              followCursor\n                ? \"transition-[opacity,scale] duration-(--motion-moderate) ease-spring\"\n                : \"transition-[opacity,translate,scale] duration-(--motion-moderate) ease-spring\",\n              \"data-[ending-style]:duration-(--motion-moderate-exit)\",\n              \"data-[starting-style]:opacity-0 data-[starting-style]:scale-95\",\n              \"data-[ending-style]:opacity-0 data-[ending-style]:scale-95\",\n              followCursor ? null : getSlideClasses(side),\n              className\n            )}\n            style={{\n              fontVariationSettings: fontWeights.medium,\n              ...(followCursor === \"y\"\n                ? { translate: \"0 var(--tooltip-follow, 0px)\" }\n                : followCursor === \"x\"\n                  ? { translate: \"var(--tooltip-follow, 0px) 0\" }\n                  : {}),\n            }}\n          >\n            {content}\n          </TooltipPrimitive.Popup>\n        </TooltipPrimitive.Positioner>\n      </TooltipPrimitive.Portal>\n    </TooltipPrimitive.Root>\n  );\n\n  // Fallback: without an ambient TooltipProvider, give this instance its own\n  // so a bare <Tooltip> keeps the library's default delay. Grouped skip-delay\n  // needs the shared app-level TooltipProvider.\n  if (hasAmbientProvider) return tooltip;\n\n  return (\n    <TooltipPrimitive.Provider delay={delayDuration ?? DEFAULT_DELAY}>\n      {tooltip}\n    </TooltipPrimitive.Provider>\n  );\n}\n\nexport { Tooltip, TooltipPortalContainer, TooltipProvider };\nexport type { TooltipProps, TooltipProviderProps, TooltipSide };\n",
      "type": "registry:ui",
      "target": "components/ui/tooltip.tsx"
    }
  ],
  "docs": "Docs & live playground: https://www.deltacomponents.dev/docs/tooltip.",
  "categories": [
    "components"
  ],
  "type": "registry:ui"
}
