{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"motion-tooltip","type":"registry:component","title":"Tooltip (Motion)","description":"Tooltip with motion: it springs out of its trigger from the side it opens on, and when its words change it glides to their width as they rise in.","dependencies":["motion","radix-ui"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-portal.json","https://dashral.com/r/motion-reduced.json","https://dashral.com/r/motion-tokens.json","https://dashral.com/r/tooltip.json"],"files":[{"path":"registry/dashral/motion/tooltip.tsx","type":"registry:file","target":"components/dashral/motion/tooltip.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { Tooltip as TooltipPrimitive } from \"radix-ui\"\nimport { AnimatePresence, animate, motion, useMotionValue, useSpring, useTransform } from \"motion/react\"\nimport type { TooltipProps as KitTooltipProps } from \"../tooltip\"\nimport { cn } from \"../lib/utils\"\nimport { usePortalContainer } from \"../lib/portal\"\n\nimport { useReducedMotion } from \"../lib/motion-reduced\"\nimport { duration, ease, spring } from \"../lib/motion-tokens\"\n\nexport type TooltipProps = KitTooltipProps & {\n  /**\n   * The tooltip trails the pointer along its trigger, a little behind, as if on a string: across a wide trigger (a\n   * chart, a row) it stays by the pointer instead of in the middle. Off by default.\n   */\n  follow?: boolean\n}\n\n// How far it starts back toward its trigger, by side: a tooltip above rises up out of it, one below drops down.\nconst offsets = { top: { x: 0, y: 4 }, bottom: { x: 0, y: -4 }, left: { x: 4, y: 0 }, right: { x: -4, y: 0 } } as const\n\n/** What the content is, for telling new words from a re-render of the same ones; only text can cross over. */\nfunction contentKey(node: React.ReactNode) {\n  return typeof node === \"string\" || typeof node === \"number\" ? String(node) : null\n}\n\n/**\n * The kit's Tooltip with motion: the same content, side, align, delay and props. It springs out of its trigger, from\n * a little smaller and back toward it, and settles with a touch of overshoot; it leaves quickly. When the words change\n * while it shows (\"Copy\" to \"Copied\"), the new ones rise in and the tooltip glides to their width, staying centred on\n * the trigger. With follow, it trails the pointer along the trigger on a soft spring. With reduced motion it only fades\n * and stays put.\n */\nexport function Tooltip({ content, children, side = \"top\", align, delay = 0, follow = false, className, open, defaultOpen, onOpenChange, ...triggerProps }: TooltipProps) {\n  const portalContainer = usePortalContainer()\n  const reduced = useReducedMotion()\n  // Kept here, so the tooltip can stay on screen while it leaves.\n  const [own, setOwn] = React.useState(defaultOpen ?? false)\n  const isOpen = open ?? own\n  const change = (next: boolean) => {\n    if (open === undefined) setOwn(next)\n    onOpenChange?.(next)\n  }\n\n  // The width glides only when the words change; before it's measured it's natural.\n  const words = React.useRef<HTMLSpanElement>(null)\n  const width = useMotionValue<number | \"auto\">(\"auto\")\n  const key = contentKey(content)\n  const lastKey = React.useRef(key)\n  const gliding = React.useRef(false)\n  React.useLayoutEffect(() => {\n    if (lastKey.current === key) return\n    lastKey.current = key\n    gliding.current = true\n  }, [key])\n  React.useEffect(() => {\n    const node = words.current\n    if (!isOpen || !node) return\n    const observer = new ResizeObserver(([entry]) => {\n      const next = entry?.borderBoxSize[0]?.inlineSize ?? node.offsetWidth\n      if (!next) return\n      if (!gliding.current || reduced || width.get() === \"auto\") {\n        width.jump(next)\n        gliding.current = false\n        return\n      }\n      animate(width, next, { ...spring.glide, onComplete: () => (gliding.current = false) })\n    })\n    observer.observe(node)\n    return () => {\n      observer.disconnect()\n      width.jump(\"auto\")\n    }\n  }, [isOpen, reduced, width])\n\n  // Following: the pointer's distance from the trigger's middle along the tooltip's side, trailed by a spring.\n  const across = side === \"top\" || side === \"bottom\"\n  const lead = useMotionValue(0)\n  const trail = useSpring(lead, { stiffness: 260, damping: 24 })\n  const followPx = useTransform(trail, (v) => `${v}px`)\n  const track = (event: React.PointerEvent<HTMLElement>) => {\n    if (!follow || reduced) return\n    const box = event.currentTarget.getBoundingClientRect()\n    const offset = across ? event.clientX - (box.left + box.width / 2) : event.clientY - (box.top + box.height / 2)\n    const half = (across ? box.width : box.height) / 2\n    // Where it opens, it's already by the pointer; after that it trails.\n    if (!isOpen) trail.jump(offset)\n    lead.set(Math.max(-half, Math.min(half, offset)))\n  }\n\n  const from = reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9, ...offsets[side] }\n\n  return (\n    <TooltipPrimitive.Provider delayDuration={delay}>\n      <TooltipPrimitive.Root data-slot=\"tooltip\" open={isOpen} onOpenChange={change}>\n        <TooltipPrimitive.Trigger\n          asChild\n          onPointerMove={track}\n          onPointerEnter={track}\n          {...triggerProps}\n          onFocus={(event) => {\n            triggerProps.onFocus?.(event)\n            // As in the kit's: a focus a script moved after a click (a popover opening) doesn't show it, only one you see.\n            if (!(event.target as Element).matches?.(\":focus-visible\")) event.preventDefault()\n          }}\n        >\n          {children}\n        </TooltipPrimitive.Trigger>\n        <AnimatePresence>\n          {isOpen && (\n            <TooltipPrimitive.Portal forceMount container={portalContainer}>\n              <TooltipPrimitive.Content forceMount asChild side={side} align={align} sideOffset={4}>\n                <motion.div\n                  data-slot=\"tooltip-content\"\n                  // It grows from the side that faces the trigger (Radix works the point out, arrow included).\n                  style={{ transformOrigin: \"var(--radix-tooltip-content-transform-origin)\", ...(follow && !reduced ? { \"--follow\": followPx } : {}) }}\n                  initial={from}\n                  animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}\n                  exit={{ ...from, transition: { duration: duration.fast, ease: ease.exit } }}\n                  transition={reduced ? { duration: duration.fast } : { ...spring.appear, opacity: { duration: duration.fast, ease: ease.enter } }}\n                  className={cn(\n                    \"z-50 w-fit rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background\",\n                    // The follow rides on translate, apart from the transform motion springs it in with.\n                    follow && !reduced && (across ? \"translate-x-(--follow)\" : \"translate-y-(--follow)\"),\n                    className\n                  )}\n                >\n                  <motion.span className=\"flex justify-center\" style={{ width }}>\n                    <span ref={words} className=\"grid w-max max-w-[min(20rem,calc(100vw-2rem))]\">\n                      {key === null ? (\n                        content\n                      ) : (\n                        // popLayout lifts the old words out of the flow at once, so the new ones set the width straight away.\n                        <AnimatePresence mode=\"popLayout\" initial={false}>\n                          <motion.span\n                            key={key}\n                            className=\"col-start-1 row-start-1\"\n                            initial={reduced ? { opacity: 0 } : { opacity: 0, y: 4 }}\n                            animate={{ opacity: 1, y: 0 }}\n                            exit={{ opacity: 0, transition: { duration: 0.1, ease: ease.exit } }}\n                            transition={{ duration: duration.standard, ease: ease.enter }}\n                          >\n                            {content}\n                          </motion.span>\n                        </AnimatePresence>\n                      )}\n                    </span>\n                  </motion.span>\n                  <TooltipPrimitive.Arrow className=\"z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground\" />\n                </motion.div>\n              </TooltipPrimitive.Content>\n            </TooltipPrimitive.Portal>\n          )}\n        </AnimatePresence>\n      </TooltipPrimitive.Root>\n    </TooltipPrimitive.Provider>\n  )\n}\n"}]}