{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "author": "DesignX ", "name": "motion-tooltip", "type": "registry:component", "title": "Tooltip", "description": "Tooltips that glide between anchors and can follow the cursor.", "dependencies": [ "@floating-ui/dom@^1.8.0", "motion@^14.0.0" ], "registryDependencies": [ "@dx/lib-ease", "@dx/utils" ], "files": [ { "path": "components/motion/tooltip.tsx", "type": "registry:component", "target": "components/motion/tooltip.tsx", "content": "import { AnimatePresence } from \"motion/react\";\nimport { TooltipPositioner } from \"./tooltip/positioner\";\nimport { useTooltipPointer } from \"./tooltip/use-position\";\nimport {\n cloneElement,\n isValidElement,\n type PointerEvent,\n type ReactElement,\n type ReactNode,\n type RefObject,\n useCallback,\n useEffect,\n useId,\n useRef,\n useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { TooltipSurface } from \"@/components/motion/tooltip-surface\";\nimport { useDismiss } from \"@/lib/hooks/use-dismiss\";\nimport { useHoverGesture } from \"@/lib/hooks/use-hover-gesture\";\nimport { useTapGesture } from \"@/lib/hooks/use-tap-gesture\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"right\" | \"bottom\" | \"left\";\n\nexport interface TooltipProps {\n content: ReactNode;\n children?: ReactElement;\n /** Existing trigger for controlled integrations such as chart cells. */\n anchorRef?: RefObject;\n /** Point within the anchor, as fractions of its rendered width and height. */\n anchorPoint?: { x: number; y: number };\n /** Follow real pointer coordinates; keyboard focus still uses the anchor. */\n followCursor?: boolean;\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n id?: string;\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\n// Once any tooltip has just closed, neighbouring tooltips open without the\n// initial delaymoving along a toolbar feels instant after the first one.\nconst WARM_WINDOW_MS = 300;\nlet lastHiddenAt = 0;\n\nexport function Tooltip({\n content,\n children,\n side = \"top\",\n delay = 120,\n className,\n wrapperClassName,\n anchorRef: externalAnchorRef,\n anchorPoint,\n followCursor = false,\n open: controlledOpen,\n onOpenChange,\n id: providedId,\n}: TooltipProps) {\n const [internalOpen, setInternalOpen] = useState(false);\n const open = controlledOpen ?? internalOpen;\n const setOpen = useCallback(\n (next: boolean) => {\n if (controlledOpen === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n },\n [controlledOpen, onOpenChange],\n );\n const generatedId = useId();\n const id = providedId ?? generatedId;\n const timer = useRef | null>(null);\n const wrapperRef = useRef(null);\n const anchorRef = externalAnchorRef ?? wrapperRef;\n const hover = useHoverGesture();\n const floatingRef = useRef(null);\n const pointer = useTooltipPointer(anchorRef, followCursor);\n const focused = useRef(false);\n\n const show = useCallback(() => {\n if (timer.current) clearTimeout(timer.current);\n if (open) return;\n const warm = Date.now() - lastHiddenAt < WARM_WINDOW_MS;\n if (warm) {\n setOpen(true);\n return;\n }\n timer.current = setTimeout(() => {\n setOpen(true);\n }, delay);\n }, [delay, setOpen, open]);\n\n const hide = useCallback(() => {\n if (timer.current) {\n clearTimeout(timer.current);\n timer.current = null;\n }\n if (open) lastHiddenAt = Date.now();\n setOpen(false);\n }, [open, setOpen]);\n\n const leave = useCallback(() => {\n if (focused.current) return;\n if (timer.current) clearTimeout(timer.current);\n // Bridge the small physical gap to a stationary, readable tooltip.\n if (followCursor) hide();\n else timer.current = setTimeout(hide, 100);\n }, [followCursor, hide]);\n const insideTooltip = useCallback(\n (target: Element) => Boolean(floatingRef.current?.contains(target)),\n [],\n );\n\n // A finger never hovers, and Safari does not focus a button on tap either, so\n // the label is only reachable if the tap itself opens the tooltip. A click\n // carries no pointerType, so the pointerdown that preceded it is what says\n // whether this was a tap; keyboard activation arrives with no pointerdown at\n // all, and focus has already shown the label there.\n const tap = useTapGesture();\n\n const toggleOnTap = useCallback(() => {\n const gesture = tap.take();\n if (!gesture || gesture.pointerType === \"mouse\") return;\n if (gesture.state) {\n hide();\n return;\n }\n if (timer.current) clearTimeout(timer.current);\n setOpen(true);\n }, [hide, tap, setOpen]);\n\n // ...and closed again by the next tap that lands somewhere else. The label\n // covers nothing interactive, so that tap passes through to what it hit.\n useDismiss(open, hide, anchorRef, { ignore: insideTooltip });\n\n useEffect(\n () => () => {\n if (timer.current) clearTimeout(timer.current);\n },\n [],\n );\n\n if (!externalAnchorRef && !isValidElement(children)) return children;\n\n // The label describes the trigger, so it has to name the trigger itself.\n // Everything else the tooltip needs is read off the anchor below instead of\n // cloned on: a handler written onto the child is the child's handler as far\n // as that child can tell, and a component that owns its activation —\n // hard-wiring onClick and spreading the rest of its props over it, as\n // ThemeToggle doesthen runs the tooltip's instead of its own. Composing\n // with `props.onClick` cannot save it either, because a component element's\n // props hold nothing the component does internally.\n const trigger = isValidElement(children)\n ? cloneElement(children as ReactElement>, {\n \"aria-describedby\":\n [(children.props as Record)[\"aria-describedby\"], open ? id : undefined]\n .filter(Boolean)\n .join(\" \") || undefined,\n })\n : null;\n\n return (\n <>\n {!externalAnchorRef ? (\n // oxlint-disable-next-line jsx-a11y/no-static-element-interactions\n {\n if (hover.enter(event)) show();\n }}\n onPointerLeave={(event: PointerEvent) => {\n if (hover.leave(event)) leave();\n }}\n onFocus={() => {\n focused.current = true;\n show();\n }}\n onBlur={() => {\n focused.current = false;\n hide();\n }}\n onPointerDown={(event: PointerEvent) => tap.start(event, open)}\n // A gesture the platform took away sends no click, and a key press\n // starts an activation that never had a pointer behind it. Either way\n // the record has to go, or the next click reads a finger that has long\n // since lifted.\n onPointerCancel={tap.drop}\n onKeyDown={(event) => {\n tap.drop();\n if (event.key === \"Escape\") hide();\n }}\n onClick={toggleOnTap}\n >\n {trigger}\n \n ) : null}\n {typeof document !== \"undefined\"\n ? createPortal(\n \n {open ? (\n \n {(positioned, isPresent) => (\n {\n if (timer.current) clearTimeout(timer.current);\n }}\n onPointerLeave={leave}\n style={{\n maxWidth: \"calc(100vw - 16px)\",\n whiteSpace: \"normal\",\n pointerEvents: isPresent && !followCursor ? \"auto\" : \"none\",\n }}\n className={cn(\"overflow-hidden\", className)}\n >\n {content}\n \n )}\n \n ) : null}\n ,\n document.body,\n )\n : null}\n \n );\n}\n" }, { "path": "components/motion/tooltip/positioner.tsx", "type": "registry:component", "target": "components/motion/tooltip/positioner.tsx", "content": "import { useIsPresent } from \"motion/react\";\nimport { useCallback, useState, type ReactNode } from \"react\";\nimport { useTooltipPosition } from \"./use-position\";\n\ntype PositionProps = Parameters[0];\n\n/** Readiness lives with the mounted overlay, not a trigger ref's attach/detach cycle. */\nexport function TooltipPositioner({\n children,\n ...position\n}: Omit & {\n children: (ready: boolean, present: boolean) => ReactNode;\n}) {\n const present = useIsPresent();\n const [ready, setReady] = useState(false);\n const onPosition = useCallback(() => setReady(true), []);\n useTooltipPosition({ ...position, open: present, onPosition });\n return (\n \n {children(ready, present)}\n \n );\n}\n" }, { "path": "components/motion/tooltip/use-position.ts", "type": "registry:component", "target": "components/motion/tooltip/use-position.ts", "content": "import {\n autoUpdate,\n computePosition,\n flip,\n offset,\n shift,\n type Placement,\n type VirtualElement,\n} from \"@floating-ui/dom\";\nimport { useCallback, useLayoutEffect, useRef, type RefObject } from \"react\";\n\nexport type TooltipSide = \"top\" | \"right\" | \"bottom\" | \"left\";\nexport type TooltipPoint = { x: number; y: number };\n\n/** Position is geometry, not animation. One write per frame, never a spring chasing a pointer. */\nexport function useTooltipPosition({\n open,\n anchorRef,\n floatingRef,\n anchorPoint,\n followCursor,\n side,\n onDismiss,\n onPosition,\n pointer,\n}: {\n open: boolean;\n anchorRef: RefObject;\n floatingRef: RefObject;\n anchorPoint?: TooltipPoint;\n followCursor: boolean;\n side: TooltipSide;\n onDismiss: () => void;\n onPosition: () => void;\n pointer: ReturnType;\n}) {\n const { cursor, onMove } = pointer;\n const cursorSide = useRef(null);\n useLayoutEffect(() => {\n cursorSide.current = open ? side : null;\n }, [open, side]);\n const frame = useRef(null);\n const version = useRef(0);\n const update = useRef<() => void>(() => {});\n const schedule = useCallback(() => {\n // Invalidate older async calculations as soon as new geometry is requested.\n version.current++;\n if (frame.current !== null) return;\n frame.current = requestAnimationFrame(() => {\n frame.current = null;\n update.current();\n });\n }, []);\n useLayoutEffect(() => {\n onMove.current = schedule;\n return () => {\n onMove.current = null;\n };\n }, [onMove, schedule]);\n const pointX = anchorPoint?.x;\n const pointY = anchorPoint?.y;\n\n // Latest committed inputs are read without recreating observers for every period/content update.\n useLayoutEffect(() => {\n update.current = () => {\n const anchor = anchorRef.current;\n const floating = floatingRef.current;\n if (!open || !anchor || !floating) return;\n const revision = ++version.current;\n const currentCursor = followCursor ? cursor.current : null;\n const reference: Element | VirtualElement =\n currentCursor || pointX !== undefined || pointY !== undefined\n ? {\n contextElement: anchor,\n getBoundingClientRect: () => {\n const rect = anchor.getBoundingClientRect();\n const x = currentCursor?.x ?? rect.left + rect.width * (pointX ?? 0.5);\n const y = currentCursor?.y ?? rect.top + rect.height * (pointY ?? 0.5);\n return { x, y, left: x, right: x, top: y, bottom: y, width: 0, height: 0 };\n },\n }\n : anchor;\n void computePosition(reference, floating, {\n strategy: \"fixed\",\n placement: currentCursor ? (cursorSide.current ?? side) : side,\n middleware: [offset(currentCursor ? 12 : 8), flip({ padding: 8 }), shift({ padding: 8 })],\n }).then(({ x, y, placement }) => {\n if (version.current !== revision || !floating.isConnected) return;\n // Hold the chosen side for this hover session. Crossing a flip threshold\n // repeatedly must not bounce the surface above and below the pointer.\n if (currentCursor) cursorSide.current = placement;\n else cursorSide.current = null;\n const dpr = window.devicePixelRatio || 1;\n floating.style.transform = `translate3d(${Math.round(x * dpr) / dpr}px, ${Math.round(y * dpr) / dpr}px, 0)`;\n const origin = {\n top: \"center bottom\",\n bottom: \"center top\",\n left: \"right center\",\n right: \"left center\",\n };\n floating.style.setProperty(\n \"--tooltip-origin\",\n origin[placement.split(\"-\")[0] as TooltipSide],\n );\n floating.style.visibility = \"visible\";\n floating.dataset.placement = placement;\n onPosition();\n });\n };\n if (open) schedule();\n }, [\n open,\n anchorRef,\n floatingRef,\n followCursor,\n pointX,\n pointY,\n side,\n schedule,\n onPosition,\n cursor,\n ]);\n\n useLayoutEffect(() => {\n const anchor = anchorRef.current;\n const floating = floatingRef.current;\n if (!open || !anchor || !floating) return;\n const stop = autoUpdate(anchor, floating, schedule);\n const onScroll = () => {\n // A stationary pointer no longer describes the same chart point after scrolling.\n if (followCursor && cursor.current) onDismiss();\n };\n window.addEventListener(\"scroll\", onScroll, true);\n return () => {\n stop();\n window.removeEventListener(\"scroll\", onScroll, true);\n // oxlint-disable-next-line react-hooks/exhaustive-deps\n version.current++;\n if (frame.current !== null) cancelAnimationFrame(frame.current);\n frame.current = null;\n };\n }, [open, anchorRef, floatingRef, followCursor, onDismiss, schedule, cursor]);\n\n useLayoutEffect(\n () => () => {\n version.current++;\n if (frame.current !== null) cancelAnimationFrame(frame.current);\n },\n [],\n );\n}\n\n/** Pointer lifetime belongs to the trigger, including the opening delay. */\nexport function useTooltipPointer(\n anchorRef: RefObject,\n followCursor: boolean,\n) {\n const cursor = useRef(null);\n const onMove = useRef<(() => void) | null>(null);\n const pointerFocus = useRef(false);\n useLayoutEffect(() => {\n const anchor = anchorRef.current;\n if (!anchor || !followCursor) return;\n const point = (event: PointerEvent) => {\n if (event.type === \"pointermove\" && event.pointerType === \"touch\" && !event.buttons) return;\n cursor.current = { x: event.clientX, y: event.clientY };\n if (event.type === \"pointerdown\") pointerFocus.current = true;\n onMove.current?.();\n };\n const keyboard = () => {\n cursor.current = null;\n pointerFocus.current = false;\n onMove.current?.();\n };\n const focus = () => {\n if (!pointerFocus.current) cursor.current = null;\n pointerFocus.current = false;\n onMove.current?.();\n };\n const leave = () => {\n cursor.current = null;\n };\n anchor.addEventListener(\"pointerenter\", point as EventListener, { passive: true });\n anchor.addEventListener(\"pointermove\", point as EventListener, { passive: true });\n anchor.addEventListener(\"pointerdown\", point as EventListener, { passive: true });\n anchor.addEventListener(\"pointerleave\", leave);\n anchor.addEventListener(\"pointercancel\", leave);\n anchor.addEventListener(\"keydown\", keyboard);\n anchor.addEventListener(\"focusin\", focus);\n return () => {\n cursor.current = null;\n anchor.removeEventListener(\"pointerenter\", point as EventListener);\n anchor.removeEventListener(\"pointermove\", point as EventListener);\n anchor.removeEventListener(\"pointerdown\", point as EventListener);\n anchor.removeEventListener(\"pointerleave\", leave);\n anchor.removeEventListener(\"pointercancel\", leave);\n anchor.removeEventListener(\"keydown\", keyboard);\n anchor.removeEventListener(\"focusin\", focus);\n };\n }, [anchorRef, followCursor]);\n\n return { cursor, onMove };\n}\n" }, { "path": "components/motion/tooltip-surface.tsx", "type": "registry:component", "target": "components/motion/tooltip-surface.tsx", "content": "import { motion, useReducedMotion } from \"motion/react\";\nimport type { ComponentProps, ReactNode, Ref } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n/** Presentation only: the unanimated parent owns measurement and positioning. */\nexport function TooltipSurface({\n children,\n side: _side = \"top\",\n className,\n ref,\n ready = true,\n style,\n ...props\n}: Omit, \"children\"> & {\n children?: ReactNode;\n /** Start the entrance only after the positioning layer has been measured. */\n ready?: boolean;\n side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n ref?: Ref;\n}) {\n const reduce = useReducedMotion();\n const closed = { opacity: 0, scale: reduce ? 1 : 0.94 };\n return (\n \n {children}\n \n );\n}\n" } ], "categories": [ "motion-kit" ], "meta": { "group": "motion", "docs": "https://dxuireact.com/docs/components/motion-tooltip" } }