{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["navigation","minimal"],"dependencies":["@floating-ui/react","motion"],"description":"Tooltip with a direction aware pixel grid background animation","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"./tooltip\";\n\nconst Preview = () => (\n  <div className=\"flex h-screen items-center justify-center bg-slate-800 text-slate-50\">\n    <Tooltip>\n      <TooltipTrigger className=\"p-4\">Hover</TooltipTrigger>\n      <TooltipContent type=\"block\">\n        <div style={{ aspectRatio: \"16/9\", width: 200 }} />\n      </TooltipContent>\n    </Tooltip>\n  </div>\n);\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/tooltip-grid/preview.tsx","type":"registry:file"},{"content":"declare module \"*.css\" {}\n","path":"/styles.d.ts","target":"uicapsule/tooltip-grid/styles.d.ts","type":"registry:file"},{"content":".tooltip {\n  --colors-panel: #020617;\n  --colors-shadowLight: hsl(206 22% 7% / 35%);\n  --colors-shadowDark: hsl(206 22% 7% / 20%);\n\n  background-color: var(--colors-panel);\n  box-shadow:\n    var(--colors-shadowLight) 0px 10px 38px -10px,\n    var(--colors-shadowDark) 0px 10px 20px -15px;\n  border-radius: 4px;\n}\n\n.tooltip.block {\n  background-color: transparent;\n  box-shadow: none;\n}\n\n.tooltip.block .content {\n  position: relative;\n}\n\n.line {\n  --mask-background: #ffffff;\n  --fade-stop: 90%;\n  --border-color: #334155;\n\n  position: fixed;\n  pointer-events: none;\n  top: 0;\n  mask-composite: exclude;\n}\n\n.lineV {\n  width: 1px;\n  height: 100dvh;\n\n  background: linear-gradient(\n    to bottom,\n    var(--border-color),\n    var(--border-color) 50%,\n    transparent 0,\n    transparent\n  );\n  background-size: 1px 5px;\n\n  -webkit-mask:\n    linear-gradient(to top, var(--mask-background) var(--fade-stop), transparent),\n    linear-gradient(to bottom, var(--mask-background) var(--fade-stop), transparent),\n    linear-gradient(black, black);\n}\n\n.lineH {\n  width: 100dvw;\n  height: 1px;\n\n  background: linear-gradient(\n    to right,\n    var(--border-color),\n    var(--border-color) 50%,\n    transparent 0,\n    transparent\n  );\n  background-size: 5px 1px;\n\n  -webkit-mask:\n    linear-gradient(to left, var(--mask-background) var(--fade-stop), transparent),\n    linear-gradient(to right, var(--mask-background) var(--fade-stop), transparent),\n    linear-gradient(black, black);\n}\n\n.blocksContainer {\n  position: absolute;\n  top: 0;\n  left: 0;\n  width: 100%;\n  height: 100%;\n  display: grid;\n  grid-template-columns: repeat(var(--cols), 1fr);\n  grid-template-rows: repeat(var(--rows), 1fr);\n}\n\n.block {\n  background-color: var(--colors-panel);\n}\n","path":"/tooltip.css","target":"uicapsule/tooltip-grid/tooltip.css","type":"registry:file"},{"content":"\"use client\";\n\nimport { cloneElement, createContext, use, useCallback, useMemo, useState } from \"react\";\nimport type { CSSProperties, HTMLProps, ReactElement, ReactNode } from \"react\";\n\nimport {\n  autoUpdate,\n  flip,\n  FloatingPortal,\n  offset,\n  shift,\n  useDismiss,\n  useFloating,\n  useFocus,\n  useHover,\n  useInteractions,\n  useMergeRefs,\n  useRole,\n} from \"@floating-ui/react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nimport type { Placement } from \"@floating-ui/react\";\n\nimport \"./tooltip.css\";\n\ntype HoverDirection =\n  | \"top-left\"\n  | \"top-center\"\n  | \"top-right\"\n  | \"bottom-left\"\n  | \"bottom-center\"\n  | \"bottom-right\"\n  | \"left\"\n  | \"right\";\n\nconst DEFAULT_HOVER_DIRECTION: HoverDirection = \"top-center\";\n\n/**\n * Which edge/corner of the trigger the pointer crossed on entry. Drives the\n * direction the block grid wipes in. A pointer that enters within\n * `EDGE_THRESHOLD_RATIO` of the trigger's centre line on the minor axis counts\n * as a straight edge entry; anything further out is a corner entry.\n */\nconst EDGE_THRESHOLD_RATIO = 0.25;\n\nconst resolveHoverDirection = (\n  rect: Pick<DOMRect, \"left\" | \"top\" | \"width\" | \"height\">,\n  pointerX: number,\n  pointerY: number,\n): HoverDirection => {\n  const deltaX = pointerX - (rect.left + rect.width / 2);\n  const deltaY = pointerY - (rect.top + rect.height / 2);\n  const thresholdX = rect.width * EDGE_THRESHOLD_RATIO;\n  const thresholdY = rect.height * EDGE_THRESHOLD_RATIO;\n\n  if (Math.abs(deltaX) > Math.abs(deltaY)) {\n    if (Math.abs(deltaY) < thresholdY) {\n      return deltaX > 0 ? \"right\" : \"left\";\n    }\n    if (deltaY < 0) {\n      return deltaX > 0 ? \"top-right\" : \"top-left\";\n    }\n    return deltaX > 0 ? \"bottom-right\" : \"bottom-left\";\n  }\n\n  if (Math.abs(deltaX) < thresholdX) {\n    return deltaY > 0 ? \"bottom-center\" : \"top-center\";\n  }\n  if (deltaX < 0) {\n    return deltaY > 0 ? \"bottom-left\" : \"top-left\";\n  }\n  return deltaY > 0 ? \"bottom-right\" : \"top-right\";\n};\n\ninterface TooltipOptions {\n  initialOpen?: boolean;\n  placement?: Placement;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}\n\nexport const useTooltip = ({\n  initialOpen = false,\n  placement = \"top\",\n  open: controlledOpen,\n  onOpenChange: setControlledOpen,\n}: TooltipOptions = {}) => {\n  const [uncontrolledOpen, setUncontrolledOpen] = useState(initialOpen);\n  const [hoverDirection, setHoverDirection] = useState<HoverDirection>(DEFAULT_HOVER_DIRECTION);\n\n  const open = controlledOpen ?? uncontrolledOpen;\n  const setOpen = useCallback(\n    (nextOpen: boolean) => {\n      if (controlledOpen === undefined) {\n        setUncontrolledOpen(nextOpen);\n      }\n      setControlledOpen?.(nextOpen);\n    },\n    [controlledOpen, setControlledOpen],\n  );\n\n  const data = useFloating({\n    middleware: [\n      offset(5),\n      flip({\n        fallbackAxisSideDirection: \"start\",\n        padding: 5,\n      }),\n      shift({ padding: 5 }),\n    ],\n    onOpenChange: (isOpen, event) => {\n      const { reference } = data.elements;\n      if (isOpen && !open && reference && event instanceof MouseEvent) {\n        setHoverDirection(\n          resolveHoverDirection(reference.getBoundingClientRect(), event.clientX, event.clientY),\n        );\n      }\n      // Reset direction on close so the next open starts from a known state.\n      if (!isOpen) {\n        setHoverDirection(DEFAULT_HOVER_DIRECTION);\n      }\n      setOpen(isOpen);\n    },\n    open,\n    placement,\n    strategy: \"fixed\",\n    whileElementsMounted: autoUpdate,\n  });\n\n  const { context } = data;\n\n  const hover = useHover(context, {\n    enabled: controlledOpen === undefined,\n    move: false,\n  });\n\n  const focus = useFocus(context, {\n    enabled: controlledOpen === undefined,\n  });\n  const dismiss = useDismiss(context);\n  const role = useRole(context, { role: \"tooltip\" });\n\n  const interactions = useInteractions([hover, focus, dismiss, role]);\n\n  return useMemo(\n    () => ({\n      hoverDirection,\n      open,\n      setOpen,\n      ...interactions,\n      ...data,\n    }),\n    [open, setOpen, hoverDirection, interactions, data],\n  );\n};\n\ntype TooltipContextValue = ReturnType<typeof useTooltip>;\n\n/** Floating UI reports the position as numbers, but they are not meaningful\n * until the floating element has been measured. */\nconst isPlaced = ({ x, y }: Pick<TooltipContextValue, \"x\" | \"y\">) =>\n  x !== null && x !== undefined && y !== null && y !== undefined;\n\nconst TooltipContext = createContext<TooltipContextValue | null>(null);\n\nexport const useTooltipContext = () => {\n  const context = use(TooltipContext);\n\n  if (context === null) {\n    throw new Error(\"Tooltip components must be wrapped in <Tooltip />\");\n  }\n\n  return context;\n};\n\nexport const Tooltip = ({ children, ...options }: { children: ReactNode } & TooltipOptions) => {\n  const tooltip = useTooltip(options);\n\n  return <TooltipContext.Provider value={tooltip}>{children}</TooltipContext.Provider>;\n};\n\ntype TooltipTriggerProps = HTMLProps<HTMLElement> &\n  (\n    | { asChild: true; children: ReactElement<HTMLProps<HTMLElement>> }\n    | { asChild?: false; children: ReactNode }\n  );\n\nexport const TooltipTrigger = ({\n  children,\n  asChild,\n  ref: propRef,\n  ...props\n}: TooltipTriggerProps) => {\n  const context = useTooltipContext();\n  const { ref: childRef, ...childProps } = asChild ? children.props : {};\n  const ref = useMergeRefs([context.refs.setReference, propRef, childRef]);\n\n  const stateProps = {\n    \"data-side\": context.placement.split(\"-\")[0],\n    \"data-state\": context.open ? \"open\" : \"closed\",\n  };\n\n  if (asChild) {\n    // oxlint-disable-next-line react/refs, react/no-clone-element -- asChild: the trigger props and merged ref are grafted onto the caller's element, never reading ref.current\n    return cloneElement(children, {\n      ...context.getReferenceProps({ ...props, ...childProps, ...stateProps }),\n      ref,\n    });\n  }\n\n  return (\n    <button type=\"button\" ref={ref} {...stateProps} {...context.getReferenceProps(props)}>\n      {children}\n    </button>\n  );\n};\n\nconst easeInOutQuint = (x: number) =>\n  x < 0.5 ? 16 * x * x * x * x * x : 1 - (-2 * x + 2) ** 5 / 2;\n\nconst lineTransition = {\n  duration: 1,\n  ease: easeInOutQuint,\n};\n\nconst TooltipLines = () => {\n  const context = useTooltipContext();\n  const floatingEl = context.elements.floating;\n\n  if (!floatingEl || !isPlaced(context)) {\n    return null;\n  }\n\n  return (\n    <>\n      <motion.div\n        className=\"line lineH\"\n        initial={{ opacity: 0, y: -1 }}\n        animate={{ opacity: 1, y: context.y }}\n        exit={{ opacity: 0, y: -1 }}\n        transition={lineTransition}\n      />\n      <motion.div\n        className=\"line lineH\"\n        initial={{ opacity: 0, y: \"100dvh\" }}\n        animate={{ opacity: 1, y: context.y + floatingEl.offsetHeight }}\n        exit={{ opacity: 0, y: \"100dvh\" }}\n        transition={lineTransition}\n      />\n      <motion.div\n        className=\"line lineV\"\n        initial={{ opacity: 0, x: -1 }}\n        animate={{ opacity: 1, x: context.x }}\n        exit={{ opacity: 0, x: -1 }}\n        transition={lineTransition}\n      />\n      <motion.div\n        className=\"line lineV\"\n        initial={{ opacity: 0, x: \"100dvw\" }}\n        animate={{ opacity: 1, x: context.x + floatingEl.offsetWidth }}\n        exit={{ opacity: 0, x: \"100dvw\" }}\n        transition={lineTransition}\n      />\n    </>\n  );\n};\n\nconst cols = 11;\nconst rows = 8;\nconst duration = 0.07;\nconst baseDelay = duration / 2;\nconst blocks = Array.from({ length: cols * rows }, (_, i) => i);\nconst centerCol = Math.floor(cols / 2);\n\nconst calculateDelay = (n: number) => baseDelay * Math.floor(n / cols) + baseDelay * (n % cols);\n\n// Calculate total delay for the default top direction\nconst totalDelay = calculateDelay(cols * rows);\n\n/**\n * Per-block stagger delay: distance (in grid steps) from the entry edge/corner,\n * scaled by `baseDelay`, so the wipe travels away from where the pointer came in.\n */\nconst getDirectionalDelay = (n: number, direction: HoverDirection) => {\n  const row = Math.floor(n / cols);\n  const col = n % cols;\n  const fromBottom = rows - 1 - row;\n  const fromRight = cols - 1 - col;\n\n  switch (direction) {\n    case \"left\": {\n      return baseDelay * col + baseDelay * row;\n    }\n    case \"right\": {\n      return baseDelay * fromRight + baseDelay * row;\n    }\n    case \"bottom-center\": {\n      return baseDelay * fromBottom + baseDelay * Math.abs(col - centerCol);\n    }\n    case \"top-left\": {\n      return baseDelay * row + baseDelay * col;\n    }\n    case \"top-right\": {\n      return baseDelay * row + baseDelay * fromRight;\n    }\n    case \"bottom-left\": {\n      return baseDelay * fromBottom + baseDelay * col;\n    }\n    case \"bottom-right\": {\n      return baseDelay * fromBottom + baseDelay * fromRight;\n    }\n    case \"top-center\": {\n      return baseDelay * row + baseDelay * Math.abs(col - centerCol);\n    }\n    default: {\n      return 0;\n    }\n  }\n};\n\n// React's CSSProperties has no index signature for custom properties, so widen\n// it rather than reaching for a type assertion.\ntype BlocksContainerStyle = CSSProperties & Record<`--${string}`, number>;\n\nconst blocksContainerStyle: BlocksContainerStyle = { \"--cols\": cols, \"--rows\": rows };\n\nconst TooltipBlocks = () => {\n  const context = useTooltipContext();\n\n  if (!isPlaced(context)) {\n    return null;\n  }\n\n  return (\n    <div className=\"blocksContainer\" style={blocksContainerStyle}>\n      {blocks.map((i) => (\n        <motion.div\n          key={i}\n          className=\"block\"\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          exit={{ opacity: 0 }}\n          transition={{\n            delay: getDirectionalDelay(i, context.hoverDirection),\n            duration,\n          }}\n        />\n      ))}\n    </div>\n  );\n};\n\nexport const TooltipContent = ({\n  className,\n  type = \"default\",\n  ref: propRef,\n  children,\n  ...props\n}: HTMLProps<HTMLDivElement> & {\n  type?: \"default\" | \"block\";\n}) => {\n  const context = useTooltipContext();\n  const ref = useMergeRefs([context.refs.setFloating, propRef]);\n  // Children stay out of `getFloatingProps`, which merges interaction props and\n  // would only hand them back untyped.\n  const floatingProps = context.getFloatingProps(props);\n  const blockType = type === \"block\";\n\n  const tooltipMotionProps = blockType\n    ? {}\n    : {\n        animate: { opacity: 1 },\n        exit: { opacity: 0 },\n        initial: { opacity: 0 },\n        transition: { duration: 0.23 },\n      };\n\n  const contentMotionProps = blockType\n    ? {\n        animate: {\n          opacity: 1,\n          transition: {\n            delay: context.hoverDirection.includes(\"top\") ? totalDelay * 2 : totalDelay,\n          },\n        },\n        exit: { opacity: 0 },\n        initial: { opacity: 0 },\n      }\n    : {};\n\n  return (\n    <FloatingPortal>\n      <AnimatePresence>\n        {context.open && (\n          <motion.div\n            className={`tooltip ${blockType ? \"block\" : \"\"} ${className ?? \"\"}`}\n            ref={ref}\n            style={context.floatingStyles}\n            {...tooltipMotionProps}\n            {...floatingProps}\n          >\n            {blockType && <TooltipBlocks />}\n            <motion.div className=\"content\" {...contentMotionProps}>\n              {children}\n            </motion.div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n      {blockType && <AnimatePresence>{context.open && <TooltipLines />}</AnimatePresence>}\n    </FloatingPortal>\n  );\n};\n","path":"/tooltip.tsx","target":"uicapsule/tooltip-grid/tooltip.tsx","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/tooltip-grid","name":"tooltip-grid","registryDependencies":[],"title":"Tooltip Grid","type":"registry:block"}