{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion","lucide-react"],"description":"A HoverImageList component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { ArrowUpRight } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n} from \"motion/react\";\nimport type { FocusEvent, PointerEvent, ReactNode } from \"react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nexport interface HoverImageListItem {\n  alt: string;\n  href?: string;\n  id: string;\n  image: string;\n  meta?: string;\n  title: string;\n}\n\nexport type HoverImageListRevealMode = \"cursor\" | \"fixed\";\n\nexport interface HoverImageListProps {\n  className?: string;\n  follow?: boolean;\n  imageSize?: number;\n  items: HoverImageListItem[];\n  revealMode?: HoverImageListRevealMode;\n}\n\ntype ActivationSource = \"focus\" | \"pointer\" | null;\n\nconst DEFAULT_IMAGE_SIZE = 240;\nconst SPRING_CONFIG = { damping: 26, stiffness: 260 };\nconst SKEW_SPRING_CONFIG = { damping: 18, stiffness: 220 };\nconst MAX_SKEW = 8;\nconst SKEW_FACTOR = 0.12;\nconst MAX_FRAME_DELTA_MS = 50;\n\nconst clamp = (value: number, min: number, max: number) =>\n  Math.min(Math.max(value, min), max);\n\nconst HoverImageList = ({\n  className,\n  follow = true,\n  imageSize = DEFAULT_IMAGE_SIZE,\n  items,\n  revealMode = \"cursor\",\n}: HoverImageListProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const [isHoverDevice, setIsHoverDevice] = useState(false);\n  const [activeIndex, setActiveIndex] = useState<number | null>(null);\n  const [activationSource, setActivationSource] =\n    useState<ActivationSource>(null);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const lastPointerRef = useRef<{ t: number; x: number; y: number } | null>(\n    null\n  );\n  const pendingPointerRef = useRef<{ x: number; y: number } | null>(null);\n  const frameRequestedRef = useRef(false);\n  const rafIdRef = useRef<number | null>(null);\n\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const skew = useMotionValue(0);\n  const springX = useSpring(x, SPRING_CONFIG);\n  const springY = useSpring(y, SPRING_CONFIG);\n  const springSkew = useSpring(skew, SKEW_SPRING_CONFIG);\n\n  useEffect(() => {\n    const mediaQuery = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    setIsHoverDevice(mediaQuery.matches);\n\n    const handleChange = (event: MediaQueryListEvent) => {\n      setIsHoverDevice(event.matches);\n    };\n\n    mediaQuery.addEventListener(\"change\", handleChange);\n    return () => mediaQuery.removeEventListener(\"change\", handleChange);\n  }, []);\n\n  useEffect(\n    () => () => {\n      if (rafIdRef.current !== null) {\n        cancelAnimationFrame(rafIdRef.current);\n      }\n    },\n    []\n  );\n\n  const isFixedPosition =\n    shouldReduceMotion ||\n    !isHoverDevice ||\n    activationSource === \"focus\" ||\n    revealMode === \"fixed\";\n\n  const activateRow = (index: number, source: ActivationSource) => {\n    setActiveIndex(index);\n    setActivationSource(source);\n  };\n\n  const deactivateRow = (source: ActivationSource) => {\n    setActiveIndex((current) => (current === null ? current : null));\n    setActivationSource((current) => (current === source ? null : current));\n  };\n\n  const handlePointerMove = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (isFixedPosition) {\n        return;\n      }\n\n      pendingPointerRef.current = { x: event.clientX, y: event.clientY };\n\n      if (frameRequestedRef.current) {\n        return;\n      }\n      frameRequestedRef.current = true;\n\n      rafIdRef.current = requestAnimationFrame((now) => {\n        frameRequestedRef.current = false;\n        const pending = pendingPointerRef.current;\n        const container = containerRef.current;\n        if (!(pending && container)) {\n          return;\n        }\n\n        const rect = container.getBoundingClientRect();\n        const localX = pending.x - rect.left;\n        const localY = pending.y - rect.top;\n\n        if (follow) {\n          let vx = 0;\n          if (lastPointerRef.current) {\n            const dt = Math.min(\n              now - lastPointerRef.current.t,\n              MAX_FRAME_DELTA_MS\n            );\n            if (dt > 0) {\n              vx = (localX - lastPointerRef.current.x) / dt;\n            }\n          }\n          lastPointerRef.current = { t: now, x: localX, y: localY };\n          skew.set(clamp(vx * SKEW_FACTOR, -MAX_SKEW, MAX_SKEW));\n        }\n\n        x.set(localX - imageSize / 2);\n        y.set(localY - imageSize / 2);\n      });\n    },\n    [isFixedPosition, follow, imageSize, skew, x, y]\n  );\n\n  const handleListPointerLeave = () => {\n    deactivateRow(\"pointer\");\n    lastPointerRef.current = null;\n  };\n\n  const handleListBlur = (event: FocusEvent<HTMLDivElement>) => {\n    if (\n      activationSource === \"focus\" &&\n      !event.currentTarget.contains(event.relatedTarget)\n    ) {\n      deactivateRow(\"focus\");\n    }\n  };\n\n  const activeItem = activeIndex === null ? null : items[activeIndex];\n\n  const previewPositionStyle = isFixedPosition\n    ? { height: imageSize, width: imageSize }\n    : {\n        height: imageSize,\n        left: 0,\n        top: 0,\n        width: imageSize,\n        x: follow ? springX : x,\n        y: follow ? springY : y,\n      };\n\n  const renderRowContent = (item: HoverImageListItem): ReactNode => (\n    <>\n      <span className=\"flex flex-col gap-1\">\n        <span className=\"font-medium text-foreground text-lg transition-colors duration-200 group-hover:text-brand\">\n          {item.title}\n        </span>\n        {item.meta ? (\n          <span className=\"text-muted-foreground text-sm\">{item.meta}</span>\n        ) : null}\n      </span>\n      <ArrowUpRight\n        aria-hidden=\"true\"\n        className=\"size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100\"\n      />\n    </>\n  );\n\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: only tracks pointer/focus leaving the list bounds to clear the preview\n    <div\n      className={cn(\"relative\", className)}\n      onBlur={handleListBlur}\n      onPointerLeave={handleListPointerLeave}\n      onPointerMove={handlePointerMove}\n      ref={containerRef}\n    >\n      <ul className=\"flex flex-col\">\n        {items.map((item, index) => (\n          <li\n            className=\"border-foreground/10 border-b last:border-b-0\"\n            key={item.id}\n          >\n            {item.href ? (\n              <a\n                className=\"group flex w-full items-center justify-between gap-4 py-5 text-left transition-colors duration-200\"\n                href={item.href}\n                onFocus={() => activateRow(index, \"focus\")}\n                onMouseEnter={() => {\n                  if (isHoverDevice) {\n                    activateRow(index, \"pointer\");\n                  }\n                }}\n              >\n                {renderRowContent(item)}\n              </a>\n            ) : (\n              <button\n                className=\"group flex w-full items-center justify-between gap-4 py-5 text-left transition-colors duration-200\"\n                onFocus={() => activateRow(index, \"focus\")}\n                onMouseEnter={() => {\n                  if (isHoverDevice) {\n                    activateRow(index, \"pointer\");\n                  }\n                }}\n                type=\"button\"\n              >\n                {renderRowContent(item)}\n              </button>\n            )}\n          </li>\n        ))}\n      </ul>\n\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 overflow-hidden\"\n      >\n        <motion.div\n          animate={\n            shouldReduceMotion\n              ? { opacity: activeItem ? 1 : 0 }\n              : { opacity: activeItem ? 1 : 0, scale: activeItem ? 1 : 0.94 }\n          }\n          className={cn(\n            \"absolute overflow-hidden rounded-2xl shadow-xl\",\n            isFixedPosition && \"top-1/2 right-6 -translate-y-1/2\"\n          )}\n          initial={{ opacity: 0, scale: shouldReduceMotion ? 1 : 0.94 }}\n          style={{\n            ...previewPositionStyle,\n            skewX: shouldReduceMotion || isFixedPosition ? 0 : springSkew,\n          }}\n          transition={\n            shouldReduceMotion\n              ? { duration: 0 }\n              : { bounce: 0.1, duration: 0.25, type: \"spring\" }\n          }\n        >\n          <AnimatePresence initial={false}>\n            {activeItem ? (\n              <motion.img\n                alt={activeItem.alt}\n                animate={\n                  shouldReduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }\n                }\n                className=\"absolute inset-0 h-full w-full object-cover\"\n                exit={\n                  shouldReduceMotion\n                    ? { opacity: 0, transition: { duration: 0 } }\n                    : { opacity: 0, scale: 0.96 }\n                }\n                initial={\n                  shouldReduceMotion\n                    ? { opacity: 0 }\n                    : { opacity: 0, scale: 0.96 }\n                }\n                key={activeItem.id}\n                src={activeItem.image}\n                transition={\n                  shouldReduceMotion\n                    ? { duration: 0 }\n                    : { bounce: 0.1, duration: 0.25, type: \"spring\" }\n                }\n              />\n            ) : null}\n          </AnimatePresence>\n        </motion.div>\n      </div>\n    </div>\n  );\n};\n\nexport default HoverImageList;\n","path":"index.tsx","target":"components/smoothui/hover-image-list/index.tsx","type":"registry:ui"}],"name":"hover-image-list","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Hover Image List","type":"registry:ui"}