{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"image-glide","title":"Image Glide","author":"CoDesign-Spa27","description":"An image viewer with pointer depth, soft image transitions, and a compact thumbnail rail.","dependencies":["motion"],"files":[{"path":"components/ui-components/image-glide.tsx","content":"\"use client\";\n\n/* eslint-disable @next/next/no-img-element */\n\nimport {\n  AnimatePresence,\n  motion,\n  useMotionTemplate,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n} from \"motion/react\";\nimport { type PointerEvent, useEffect, useRef, useState } from \"react\";\n\ntype ImageGlideItem = {\n  src: string;\n  alt: string;\n  title: string;\n  description?: string;\n};\n\ntype ImageGlideProps = {\n  images?: ImageGlideItem[];\n  initialIndex?: number;\n  className?: string;\n  onIndexChange?: (index: number) => void;\n};\n\nconst DEFAULT_IMAGES: ImageGlideItem[] = [\n  {\n    src: \"/assets/images/image.jpg\",\n    alt: \"Editorial scene with warm natural light\",\n    title: \"Warm Frame\",\n    description: \"A calm lead image with gentle depth and a soft foreground glow.\",\n  },\n  {\n    src: \"/assets/images/image2.jpg\",\n    alt: \"Cinematic image with layered composition\",\n    title: \"Soft Focus\",\n    description: \"A balanced crop that keeps the motion quiet and readable.\",\n  },\n  {\n    src: \"/assets/images/image3.jpg\",\n    alt: \"Detailed image with rich texture\",\n    title: \"Textured Light\",\n    description: \"Pointer movement nudges the surface without stealing attention.\",\n  },\n  {\n    src: \"/assets/images/image4.jpg\",\n    alt: \"Wide image with natural contrast\",\n    title: \"Open Field\",\n    description: \"The active image settles fast so selection still feels immediate.\",\n  },\n  {\n    src: \"/assets/images/image5.jpg\",\n    alt: \"Image with muted tones and a clean composition\",\n    title: \"Quiet Tone\",\n    description: \"A thumbnail rail gives the component a compact gallery rhythm.\",\n  },\n  {\n    src: \"/assets/images/image6.jpg\",\n    alt: \"Image with warm highlights and depth\",\n    title: \"Late Glow\",\n    description: \"The interaction is decorative, so it backs off for reduced motion.\",\n  },\n];\n\nconst easeOut = [0.23, 1, 0.32, 1] as const;\nconst sweepEase = [0.77, 0, 0.175, 1] as const;\nconst sweepMs = 560;\nconst hoverIntentMs = 70;\nconst pointerSpring = {\n  stiffness: 130,\n  damping: 20,\n  mass: 0.4,\n};\n\nfunction cn(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\nexport function ImageGlide({\n  images = DEFAULT_IMAGES,\n  initialIndex = 0,\n  className,\n  onIndexChange,\n}: ImageGlideProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const safeImages = images.length > 0 ? images : DEFAULT_IMAGES;\n  const initialSafeIndex = Math.min(Math.max(initialIndex, 0), safeImages.length - 1);\n  const [activeIndex, setActiveIndex] = useState(initialSafeIndex);\n  const [previousImage, setPreviousImage] = useState<ImageGlideItem | null>(null);\n  const [sweepKey, setSweepKey] = useState(0);\n  const activeImage = safeImages[activeIndex] ?? safeImages[0];\n  const hoverIntentRef = useRef<number | null>(null);\n\n  const imageX = useMotionValue(0);\n  const imageY = useMotionValue(0);\n  const glowX = useMotionValue(0);\n  const glowY = useMotionValue(0);\n  const glowOpacity = useMotionValue(0);\n\n  const smoothImageX = useSpring(imageX, pointerSpring);\n  const smoothImageY = useSpring(imageY, pointerSpring);\n  const smoothGlowX = useSpring(glowX, pointerSpring);\n  const smoothGlowY = useSpring(glowY, pointerSpring);\n  const smoothGlowOpacity = useSpring(glowOpacity, {\n    stiffness: 220,\n    damping: 26,\n    mass: 0.5,\n  });\n  const imageTransform = useMotionTemplate`translate3d(${smoothImageX}px, ${smoothImageY}px, 0px)`;\n  const glowTransform = useMotionTemplate`translate3d(${smoothGlowX}px, ${smoothGlowY}px, 0px) translate(-50%, -50%)`;\n\n  useEffect(() => {\n    setActiveIndex((currentIndex) => Math.min(currentIndex, safeImages.length - 1));\n  }, [safeImages.length]);\n\n  useEffect(() => {\n    return () => {\n      if (hoverIntentRef.current) {\n        window.clearTimeout(hoverIntentRef.current);\n      }\n    };\n  }, []);\n\n  function selectImage(index: number) {\n    if (index === activeIndex) {\n      return;\n    }\n\n    setPreviousImage(activeImage);\n    setActiveIndex(index);\n    setSweepKey((key) => key + 1);\n    onIndexChange?.(index);\n  }\n\n  function clearHoverIntent() {\n    if (!hoverIntentRef.current) {\n      return;\n    }\n\n    window.clearTimeout(hoverIntentRef.current);\n    hoverIntentRef.current = null;\n  }\n\n  function scheduleHoverSelect(index: number, pointerType: string) {\n    if (pointerType === \"touch\" || index === activeIndex) {\n      return;\n    }\n\n    clearHoverIntent();\n    hoverIntentRef.current = window.setTimeout(() => {\n      selectImage(index);\n      hoverIntentRef.current = null;\n    }, hoverIntentMs);\n  }\n\n  useEffect(() => {\n    if (!previousImage) {\n      return;\n    }\n\n    const timer = window.setTimeout(\n      () => {\n        setPreviousImage(null);\n      },\n      shouldReduceMotion ? 180 : sweepMs,\n    );\n\n    return () => window.clearTimeout(timer);\n  }, [previousImage, shouldReduceMotion]);\n\n  function handlePointerMove(event: PointerEvent<HTMLDivElement>) {\n    if (shouldReduceMotion || event.pointerType === \"touch\") {\n      return;\n    }\n\n    const rect = event.currentTarget.getBoundingClientRect();\n    const x = event.clientX - rect.left;\n    const y = event.clientY - rect.top;\n    const progressX = x / rect.width - 0.5;\n    const progressY = y / rect.height - 0.5;\n\n    imageX.set(progressX * -10);\n    imageY.set(progressY * -8);\n    glowX.set(x);\n    glowY.set(y);\n    glowOpacity.set(1);\n  }\n\n  function resetPointerFrame() {\n    imageX.set(0);\n    imageY.set(0);\n    glowOpacity.set(0);\n  }\n\n  return (\n    <div className={cn(\"mx-auto w-full max-w-2xl\", className)}>\n      <figure className=\"space-y-1\">\n        <div className=\"relative p-1 rounded-lg\">\n          <div\n            className=\"relative isolate aspect-[16/10] rounded-lg overflow-hidden \"\n            onPointerLeave={resetPointerFrame}\n            onPointerMove={handlePointerMove}\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.div\n                animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                className=\"absolute inset-0\"\n                initial={{\n                  opacity: shouldReduceMotion ? 0 : 1,\n                  filter: shouldReduceMotion ? \"blur(0px)\" : \"blur(2px)\",\n                }}\n                key={activeImage.src}\n                style={{\n                  transform: shouldReduceMotion ? \"none\" : imageTransform,\n                  willChange: shouldReduceMotion ? \"opacity, filter\" : \"opacity, filter, transform\",\n                }}\n                transition={{ duration: shouldReduceMotion ? 0.16 : 0.2, ease: easeOut }}\n              >\n                <img\n                  alt={activeImage.alt}\n                  className=\"h-full w-full object-cover rounded-lg\"\n                  decoding=\"async\"\n                  draggable={false}\n                  src={activeImage.src}\n                />\n              </motion.div>\n            </AnimatePresence>\n\n            <AnimatePresence initial={false}>\n              {previousImage && !shouldReduceMotion ? (\n                <motion.div\n                  animate={{ clipPath: \"inset(0 0 0 100%)\" }}\n                  className=\"absolute inset-0 z-[2]\"\n                  exit={{ opacity: 0 }}\n                  initial={{ clipPath: \"inset(0 0 0 0%)\" }}\n                  key={`${previousImage.src}-${sweepKey}`}\n                  style={{ willChange: \"clip-path\" }}\n                  transition={{ duration: sweepMs / 1000, ease: sweepEase }}\n                >\n                  <img\n                    alt=\"\"\n                    className=\"h-full w-full object-cover\"\n                    decoding=\"async\"\n                    draggable={false}\n                    src={previousImage.src}\n                  />\n                  <div className=\"absolute inset-0 bg-black/12\" />\n                </motion.div>\n              ) : null}\n            </AnimatePresence>\n\n            <AnimatePresence initial={false}>\n              {previousImage && !shouldReduceMotion ? (\n                <motion.div\n                  aria-hidden=\"true\"\n                  animate={{ transform: \"translate3d(142%, 0, 0) skewX(-9deg)\", opacity: 0 }}\n                  className=\"pointer-events-none absolute -inset-y-12 left-0 z-[3] w-[46%]\"\n                  initial={{ transform: \"translate3d(-62%, 0, 0) skewX(-9deg)\", opacity: 1 }}\n                  key={`glimm-sweep-${sweepKey}`}\n                  style={{\n                    background:\n                      \"linear-gradient(90deg, transparent 0%, rgb(0 0 0 / 0.10) 13%, rgb(20 20 20 / 0.86) 36%, rgb(160 160 160 / 0.34) 50%, rgb(255 255 255 / 0.24) 58%, rgb(38 38 38 / 0.72) 69%, transparent 100%)\",\n                    filter: \"blur(12px) saturate(0)\",\n                    mixBlendMode: \"screen\",\n                    willChange: \"transform, opacity\",\n                  }}\n                  transition={{\n                    transform: { duration: sweepMs / 1000, ease: sweepEase },\n                    opacity: { delay: 0.28, duration: 0.14, ease: easeOut },\n                  }}\n                >\n                  <div className=\"absolute inset-y-0 left-1/2 w-px bg-white/30\" />\n                  <div className=\"absolute inset-y-0 left-[46%] w-8 bg-white/8 blur-xl\" />\n                </motion.div>\n              ) : null}\n            </AnimatePresence>\n\n            <div className=\"absolute inset-0 z-[4] bg-[linear-gradient(180deg,transparent_48%,rgb(0_0_0/0.72)_100%)]\" />\n            <motion.div\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute left-0 top-0 z-[5] size-56 rounded-full bg-white/10 blur-2xl\"\n              style={{\n                opacity: smoothGlowOpacity,\n                transform: shouldReduceMotion ? \"translate3d(0, 0, 0)\" : glowTransform,\n                willChange: shouldReduceMotion ? \"opacity\" : \"opacity, transform\",\n              }}\n            />\n            <figcaption className=\"absolute inset-x-0 bottom-0 z-[6] flex items-end justify-between gap-4 p-4 text-white\">\n              <div className=\"min-w-0\">\n                <p className=\"font-mono text-sm uppercase tracking-[0.18em] text-white/55\">\n                  {String(activeIndex + 1).padStart(2, \"0\")} /{\" \"}\n                  {String(safeImages.length).padStart(2, \"0\")}\n                </p>\n                <h3 className=\"mt-1 truncate text-lg font-medium tracking-normal\">\n                  {activeImage.title}\n                </h3>\n                {activeImage.description ? (\n                  <p className=\"mt-1 line-clamp-2 max-w-md text-xs leading-5 text-white/68\">\n                    {activeImage.description}\n                  </p>\n                ) : null}\n              </div>\n            </figcaption>\n          </div>\n        </div>\n\n        <div className=\"grid grid-cols-6 gap-2\" role=\"group\" aria-label=\"Image choices\">\n          {safeImages.map((image, index) => {\n            const isActive = index === activeIndex;\n\n            return (\n              <button\n                aria-label={`Show ${image.title}`}\n                aria-pressed={isActive}\n                className={cn(\n                  \"group relative aspect-[4/3] overflow-hidden rounded-[8px] border-0 bg-muted outline-none transition-[opacity,transform,border-color] duration-[700ms] ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background \",\n                  \" \",\n                  isActive\n                    ? \" opacity-100\"\n                    : \"opacity-44 [@media(hover:hover)_and_(pointer:fine)]:hover:opacity-85\",\n                )}\n                key={image.src}\n                onClick={() => selectImage(index)}\n                onPointerEnter={(event) => scheduleHoverSelect(index, event.pointerType)}\n                onPointerLeave={clearHoverIntent}\n                type=\"button\"\n              >\n                <img\n                  alt=\"\"\n                  className=\"h-full w-full object-cover transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] group-active:scale-[0.98]\"\n                  decoding=\"async\"\n                  draggable={false}\n                  src={image.src}\n                />\n                {isActive ? (\n                  <motion.span\n                    className=\"absolute inset-0  \"\n                    layoutId=\"image-glide-active\"\n                    transition={{ type: \"spring\", duration: 0.32, bounce: 0.12 }}\n                  />\n                ) : null}\n              </button>\n            );\n          })}\n        </div>\n      </figure>\n    </div>\n  );\n}\n\nexport default ImageGlide;\n","type":"registry:ui"}],"categories":["gallery","carousel"],"type":"registry:ui"}