{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tooltip",
  "type": "registry:ui",
  "title": "Tooltip",
  "description": "A small solid label for icons, shortcuts and status. It opens after a short hover or at once on focus, then grows from its trigger.",
  "dependencies": [
    "@radix-ui/react-tooltip",
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/kbd.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/tooltip.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/tooltip 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Tooltip: a small label for an icon, a shortcut or a status.\n *\n * - Radix Tooltip is the accessibility layer (aria-describedby, Escape, hover\n *   intent, the skip window). Everything visual is ours.\n * - Flat: an ink pill with canvas text, no highlight.\n * - Look: a solid surface-3 chip with shadow-litt-tooltip. It is too small for glass\n *   to read. rounded-litt-control, text-litt-small, 6px by 8px, 240px at most. A shortcut\n *   is the shared Kbd chip (kbd.tsx).\n * - Motion (responsive tier): it grows from the trigger. Opacity 0 to 1, scale\n *   the material's grow scale (useOverlayMotion, @/motion/deal; under Device it switches on in steps) to 1, and a nudge of travel/4 toward the trigger, from the side it\n *   really renders on (Radix data-side, read after collision flipping).\n *   transform-origin is Radix's trigger-side origin. The exit is useExit, a\n *   quiet fade, run by AnimatePresence on a force-mounted Radix portal.\n * - Delays are interaction, not animation: 200ms of hover intent, then a 300ms\n *   window in which the next tooltip opens at once. Keyboard focus opens\n *   instantly (Radix does this on its own; a click never opens one).\n * - Touch (foundations.md section 6): Radix never opens on touch, and a tap is\n *   the button's own action, so a tap no longer shows a label. A press and hold\n *   of 500ms does (an interaction delay like the 200ms above, not a motion\n *   token). The hold is cancelled by lifting early, by drifting past a small\n *   slop, or by the browser taking the touch to scroll. The click a hold may\n *   leave behind is swallowed once, so reading a label never also runs the\n *   action. The next touch anywhere closes it, and a touch on the trigger\n *   still does what the trigger does. The native long-press menu and text\n *   selection are off on the trigger on coarse pointers. Shortcut chips are\n *   not rendered on coarse pointers.\n * - Both signals come from useDevice (coarse), so <DeviceProvider mode=\"phone\">\n *   previews it with a mouse: there the hold works for any pointer and hover\n *   opens nothing, as on a finger device. A touch on a pointer-fine device\n *   (a touch laptop) holds too.\n */\nimport { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type ReactElement, type ReactNode } from \"react\";\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useDevice, useExit, useTransition, useTravel } from \"../../lib/litt/motion\";\nimport { useOverlayMotion } from \"../../lib/litt/motion/deal\";\nimport { Kbd } from \"./kbd\";\n\n/* Radix's asChild, spread in: shadcn's CLI rewrites a written asChild to Base UI's render prop in base-* hosts. */\nconst asChildProps = { asChild: true } as const;\n\nexport type TooltipSide = \"top\" | \"right\" | \"bottom\" | \"left\";\n\nconst OPEN_DELAY = 200; // ms of hover intent before the first tooltip opens (interaction delay, not animation)\nconst SKIP_WINDOW = 300; // ms after one closes during which the next opens at once (interaction delay)\nconst HOLD_DELAY = 500; // ms of press and hold before a tooltip opens on touch (interaction delay, not animation)\nconst HOLD_SLOP = 10; // px a finger may drift during the hold before it counts as a scroll or a drag\nconst CLICK_WINDOW = 500; // ms after a hold ends in which the click it may leave behind is swallowed\n\nconst HasProvider = createContext(false);\n\n/**\n * Mount once near the root so every tooltip shares the skip window. Without it each\n * Tooltip adds its own provider, so tooltips still work but do not share that window.\n */\nexport function TooltipProvider({\n  children,\n}: {\n  /** Your app, or the part of it that holds tooltips. */\n  children: ReactNode;\n}) {\n  return (\n    <HasProvider.Provider value>\n      <TooltipPrimitive.Provider delayDuration={OPEN_DELAY} skipDelayDuration={SKIP_WINDOW}>\n        {children}\n      </TooltipPrimitive.Provider>\n    </HasProvider.Provider>\n  );\n}\n\nexport type TooltipProps = {\n  /** The label. Keep it short; long text wraps at 240px. On touch it only shows on a press and hold, so the label that matters must also be the trigger's aria-label or visible text. */\n  content: ReactNode;\n  /** A real keyboard shortcut, shown as a kbd chip. It is not shown on touch screens. */\n  shortcut?: string;\n  /** Which side of the trigger it opens on. With no room there it flips to the other side, and the entrance follows. */\n  side?: TooltipSide;\n  /** How it lines up along that side: with the start, the center or the end of the trigger. */\n  align?: \"start\" | \"center\" | \"end\";\n  /** Controlled open state. Leave undefined to let hover and focus decide. */\n  open?: boolean;\n  /** Whether it starts open when you do not control it. Hover and focus decide after that. */\n  defaultOpen?: boolean;\n  /** Called with true or false each time it opens or closes. Pair it with open to control it. */\n  onOpenChange?: (open: boolean) => void;\n  /** The trigger: one focusable element. */\n  children: ReactElement;\n};\n\nconst isSide = (v: string | null): v is TooltipSide => v === \"top\" || v === \"right\" || v === \"bottom\" || v === \"left\";\n\n/** A short label for the one element inside it. It opens after a hover delay, at once on focus, or on a press and hold on touch. */\nexport function Tooltip({\n  content,\n  shortcut,\n  side = \"top\",\n  align = \"center\",\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  children,\n}: TooltipProps) {\n  const hasProvider = useContext(HasProvider);\n  const { coarse } = useDevice();\n  const [inner, setInner] = useState(defaultOpen);\n  const open = openProp ?? inner;\n\n  const change = (next: boolean) => {\n    setInner(next);\n    onOpenChange?.(next);\n  };\n  const latest = useRef(change);\n  latest.current = change;\n\n  /* Press and hold. `pending` is the timer and where the finger went down. `held` is true from the moment the hold\n     opens the tooltip until that press ends; `swallowUntil` then covers the click the release may cause. */\n  const pending = useRef<{ timer: number; x: number; y: number } | null>(null);\n  const held = useRef(false);\n  const swallowUntil = useRef(0);\n  const cancelHold = () => {\n    if (pending.current) window.clearTimeout(pending.current.timer);\n    pending.current = null;\n  };\n  useEffect(() => cancelHold, []);\n\n  /* The next touch anywhere closes it, at once: Radix waits for the click of an outside touch, which a scroll never makes. */\n  useEffect(() => {\n    if (!open) return;\n    const onTouch = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\") latest.current(false);\n    };\n    document.addEventListener(\"pointerdown\", onTouch, true);\n    return () => document.removeEventListener(\"pointerdown\", onTouch, true);\n  }, [open]);\n\n  const tree = (\n    <TooltipPrimitive.Root open={open} onOpenChange={change}>\n      <TooltipPrimitive.Trigger\n        {...asChildProps}\n        className=\"pointer-coarse:select-none pointer-coarse:[-webkit-touch-callout:none]\"\n        onPointerDown={(e) => {\n          held.current = false;\n          swallowUntil.current = 0;\n          cancelHold();\n          if ((e.pointerType !== \"touch\" && !coarse) || !e.isPrimary) return;\n          pending.current = {\n            x: e.clientX,\n            y: e.clientY,\n            timer: window.setTimeout(() => {\n              pending.current = null;\n              held.current = true;\n              latest.current(true);\n            }, HOLD_DELAY),\n          };\n        }}\n        onPointerMove={(e) => {\n          const at = pending.current;\n          if (at && Math.hypot(e.clientX - at.x, e.clientY - at.y) > HOLD_SLOP) cancelHold();\n          /* A finger has no hover. This also keeps a mouse from opening it while a phone is previewed. */\n          if (coarse && e.pointerType !== \"touch\") e.preventDefault();\n        }}\n        onPointerUp={() => {\n          cancelHold();\n          if (held.current) swallowUntil.current = performance.now() + CLICK_WINDOW;\n          held.current = false;\n        }}\n        onPointerCancel={() => {\n          cancelHold();\n          held.current = false;\n        }}\n        onContextMenu={(e) => {\n          /* The long press must not also raise the browser's own menu. */\n          if (pending.current || held.current) e.preventDefault();\n        }}\n        onClickCapture={(e) => {\n          /* Reading a label is not pressing the button. Stopping it here also keeps Radix's click from closing the label. */\n          if (performance.now() < swallowUntil.current) {\n            swallowUntil.current = 0;\n            e.preventDefault();\n            e.stopPropagation();\n          }\n        }}\n      >\n        {children}\n      </TooltipPrimitive.Trigger>\n      <AnimatePresence>\n        {open && (\n          <TooltipPrimitive.Portal key=\"tooltip\" forceMount>\n            <TooltipSurface side={side} align={align} content={content} shortcut={coarse ? undefined : shortcut} />\n          </TooltipPrimitive.Portal>\n        )}\n      </AnimatePresence>\n    </TooltipPrimitive.Root>\n  );\n\n  return hasProvider ? tree : <TooltipProvider>{tree}</TooltipProvider>;\n}\n\nfunction TooltipSurface({\n  side: requested,\n  align,\n  content,\n  shortcut,\n}: Pick<TooltipProps, \"content\" | \"shortcut\" | \"align\"> & { side: TooltipSide }) {\n  const hostRef = useRef<HTMLDivElement>(null);\n  const [side, setSide] = useState<TooltipSide>(requested);\n  /* The material's motion language (@/motion/deal): it grows from om.grow, and under Device it switches on and off\n     in steps where it stands, with no slide. */\n  const om = useOverlayMotion();\n  const springIn = useTransition(\"responsive\");\n  const springOut = useExit(\"responsive\");\n  const enter = om.switches ? om.switchIn : springIn;\n  const exit = om.switches ? om.switchOut : springOut;\n  const fullTravel = useTravel();\n  const travel = om.slides ? fullTravel : 0;\n\n  /* Radix flips the tooltip when it would collide with the viewport. It writes\n     the side it really renders on to data-side, a beat after mount. Follow it,\n     and remount the surface (key) so the entrance starts on the right side. */\n  useLayoutEffect(() => {\n    const host = hostRef.current;\n    if (!host) return;\n    const read = () => {\n      const s = host.getAttribute(\"data-side\");\n      if (isSide(s)) setSide(s);\n    };\n    read();\n    const watch = new MutationObserver(read);\n    watch.observe(host, { attributes: true, attributeFilter: [\"data-side\"] });\n    return () => watch.disconnect();\n  }, []);\n\n  /* Start a quarter of the travel nearer the trigger, then move away from it. */\n  const d = travel / 4;\n  const toward = side === \"top\" ? { y: d } : side === \"bottom\" ? { y: -d } : side === \"left\" ? { x: d } : { x: -d };\n\n  return (\n    <TooltipPrimitive.Content\n      ref={hostRef}\n      side={requested}\n      align={align}\n      sideOffset={8}\n      collisionPadding={8}\n      className=\"z-50 data-[state=closed]:pointer-events-none\"\n    >\n      <motion.div\n        key={side}\n        className=\"flex max-w-60 origin-(--radix-tooltip-content-transform-origin) items-center gap-3 rounded-litt-control bg-litt-surface-3 px-2 py-1.5 text-litt-small text-litt-fg shadow-litt-tooltip inset-shadow-litt-highlight in-[[data-material=flat]]:bg-litt-fg in-[[data-material=flat]]:text-litt-canvas in-[[data-material=flat]]:inset-shadow-none in-[[data-material=flat]]:px-3 in-[[data-material=flat]]:[&_kbd]:bg-transparent in-[[data-material=flat]]:[&_kbd]:text-litt-canvas in-[[data-material=flat]]:[&_kbd]:opacity-70 in-[[data-material=flat]]:[&_kbd]:shadow-none\"\n        initial={{ opacity: 0, scale: om.grow, ...toward }}\n        animate={{ opacity: 1, scale: 1, x: 0, y: 0, transition: enter }}\n        exit={{ opacity: 0, scale: om.grow, transition: exit }}\n      >\n        <span className=\"min-w-0 text-pretty\">{content}</span>\n        {shortcut && <Kbd>{shortcut}</Kbd>}\n      </motion.div>\n    </TooltipPrimitive.Content>\n  );\n}\n",
      "target": "components/litt/tooltip.tsx"
    }
  ],
  "docs": "Import Tooltip from components/litt/tooltip. Mount TooltipProvider once near the root so tooltips share one skip window.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
