{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "infinite-scrolling-images",
  "title": "Infinite Scrolling Images",
  "description": "An infinite card stack that advances with wheel, touch, or keyboard input using spring-based Motion transitions.",
  "dependencies": ["motion"],
  "registryDependencies": ["utils"],
  "files": [
    {
      "path": "registry/catalog/infinite-scrolling-images/index.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { motion } from \"motion/react\";\nimport Image from \"next/image\";\nimport {\n  type ComponentPropsWithoutRef,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\n\nexport interface InfiniteScrollingImagesItem {\n  alt?: string;\n  src: string;\n}\n\nexport interface InfiniteScrollingImagesProps\n  extends Omit<ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** Enable automatic frame progression. @default false */\n  autoplay?: boolean;\n  /** Seconds between auto-advance steps when autoplay is enabled. @default 3 */\n  autoplayIntervalSeconds?: number;\n  /** Vertical spacing between cards in pixels. @default -30 */\n  frameOffset?: number;\n  /** Number of visible cards (excluding buffered cards). @default 3 */\n  framesVisible?: number;\n  /** Cards to display in an infinite loop. */\n  items: InfiniteScrollingImagesItem[];\n  /** Whether ArrowUp / ArrowDown keys should move frames. @default true */\n  keyboardControls?: boolean;\n  /** Minimum ms between frame updates while scrolling. @default 75 */\n  minUpdateInterval?: number;\n  /** Number of cards rendered before/after visible range. @default 8 */\n  renderBuffer?: number;\n  /** Scroll delta threshold before moving to next frame. @default 40 */\n  scrollThreshold?: number;\n}\n\nconst REMOTE_IMAGE_SRC = /^https?:\\/\\//;\n\nfunction isRemoteImageSrc(src: string): boolean {\n  return REMOTE_IMAGE_SRC.test(src);\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n  return Math.min(Math.max(value, min), max);\n}\n\nfunction wrapIndex(index: number, length: number): number {\n  return ((index % length) + length) % length;\n}\n\nfunction InfiniteScrollingImages({\n  autoplay = false,\n  autoplayIntervalSeconds = 3,\n  className,\n  frameOffset = -30,\n  framesVisible = 3,\n  items,\n  keyboardControls = true,\n  minUpdateInterval = 75,\n  renderBuffer = 8,\n  scrollThreshold = 40,\n  ...props\n}: InfiniteScrollingImagesProps) {\n  const [currentIndex, setCurrentIndex] = useState(0);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const scrollAccumulatorRef = useRef(0);\n  // Seeded at 0, not Date.now(): reading the clock during render breaks\n  // Next.js static prerendering. The actual value doesn't matter here — it\n  // only needs to be far enough in the past that the first scroll flush\n  // isn't throttled by the `elapsed < minUpdateInterval` check below.\n  const lastUpdateTimeRef = useRef(0);\n  const touchStartYRef = useRef(0);\n\n  const updateFrame = useCallback(\n    (delta: number) => {\n      if (items.length === 0 || delta === 0) {\n        return;\n      }\n      setCurrentIndex((previous) => previous + delta);\n    },\n    [items.length]\n  );\n\n  const flushScrollAccumulator = useCallback(() => {\n    const now = Date.now();\n    const elapsed = now - lastUpdateTimeRef.current;\n\n    if (Math.abs(scrollAccumulatorRef.current) < scrollThreshold) {\n      return;\n    }\n\n    if (elapsed < minUpdateInterval) {\n      return;\n    }\n\n    const delta = scrollAccumulatorRef.current > 0 ? 1 : -1;\n    updateFrame(delta);\n    scrollAccumulatorRef.current = 0;\n    lastUpdateTimeRef.current = now;\n  }, [minUpdateInterval, scrollThreshold, updateFrame]);\n\n  useEffect(() => {\n    if (!keyboardControls) {\n      return;\n    }\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"ArrowDown\" || event.key === \"ArrowRight\") {\n        updateFrame(1);\n      } else if (event.key === \"ArrowUp\" || event.key === \"ArrowLeft\") {\n        updateFrame(-1);\n      }\n    };\n\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      window.removeEventListener(\"keydown\", onKeyDown);\n    };\n  }, [keyboardControls, updateFrame]);\n\n  useEffect(() => {\n    if (!autoplay || items.length === 0) {\n      return;\n    }\n\n    const intervalMs = Math.max(0.25, autoplayIntervalSeconds) * 1000;\n    const intervalId = window.setInterval(() => {\n      updateFrame(1);\n    }, intervalMs);\n\n    return () => {\n      window.clearInterval(intervalId);\n    };\n  }, [autoplay, autoplayIntervalSeconds, items.length, updateFrame]);\n\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) {\n      return;\n    }\n\n    const onWheel = (event: WheelEvent) => {\n      event.preventDefault();\n      scrollAccumulatorRef.current += event.deltaY;\n      flushScrollAccumulator();\n    };\n\n    const onTouchStart = (event: TouchEvent) => {\n      touchStartYRef.current = event.touches[0]?.clientY ?? 0;\n    };\n\n    const onTouchMove = (event: TouchEvent) => {\n      event.preventDefault();\n      const touchY = event.touches[0]?.clientY ?? touchStartYRef.current;\n      const deltaY = touchStartYRef.current - touchY;\n      touchStartYRef.current = touchY;\n      scrollAccumulatorRef.current += deltaY;\n      flushScrollAccumulator();\n    };\n\n    container.addEventListener(\"wheel\", onWheel, { passive: false });\n    container.addEventListener(\"touchstart\", onTouchStart, { passive: false });\n    container.addEventListener(\"touchmove\", onTouchMove, { passive: false });\n\n    return () => {\n      container.removeEventListener(\"wheel\", onWheel);\n      container.removeEventListener(\"touchstart\", onTouchStart);\n      container.removeEventListener(\"touchmove\", onTouchMove);\n    };\n  }, [flushScrollAccumulator]);\n\n  const safeFramesVisible = Math.max(1, framesVisible);\n  const start = currentIndex - renderBuffer;\n  const end = currentIndex + safeFramesVisible + renderBuffer;\n  const visibleCards: Array<{ imageIndex: number; index: number }> = [];\n\n  for (let index = start; index <= end; index += 1) {\n    visibleCards.push({\n      imageIndex: wrapIndex(index, items.length),\n      index,\n    });\n  }\n\n  return (\n    <div\n      className={cn(\n        \"relative flex h-full w-full items-center justify-center overflow-hidden\",\n        className\n      )}\n      ref={containerRef}\n      {...props}\n    >\n      <div className=\"relative flex h-full w-full items-center justify-center\">\n        {visibleCards.map((card) => {\n          const item = items[card.imageIndex];\n          if (!item) {\n            return null;\n          }\n\n          const offsetIndex = card.index - currentIndex;\n          const blur = currentIndex > card.index ? 2 : 0;\n          const opacity = currentIndex > card.index ? 0 : 1;\n          const scale = clamp(1 - offsetIndex * 0.08, 0.08, 2);\n          const y = clamp(\n            offsetIndex * frameOffset,\n            frameOffset * safeFramesVisible,\n            Number.POSITIVE_INFINITY\n          );\n\n          return (\n            <motion.div\n              animate={{\n                scale,\n                y,\n                transition: {\n                  damping: 20,\n                  mass: 0.5,\n                  stiffness: 250,\n                  type: \"spring\",\n                },\n              }}\n              className=\"absolute aspect-video w-[85%] max-w-[800px] overflow-hidden rounded-lg bg-black shadow-2xl\"\n              initial={false}\n              key={card.index}\n              style={{\n                filter: `blur(${blur}px)`,\n                opacity,\n                transitionDuration: \"200ms\",\n                transitionProperty: \"opacity, filter\",\n                transitionTimingFunction: \"ease-in-out\",\n                willChange: \"opacity, filter, transform\",\n                zIndex: 1000 - card.index,\n              }}\n            >\n              <Image\n                alt={item.alt ?? \"\"}\n                className=\"h-full w-full object-cover\"\n                draggable={false}\n                fill\n                referrerPolicy={\n                  isRemoteImageSrc(item.src) ? \"no-referrer\" : undefined\n                }\n                sizes=\"(max-width: 768px) 85vw, 800px\"\n                src={item.src}\n                unoptimized={isRemoteImageSrc(item.src)}\n              />\n            </motion.div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\nexport { InfiniteScrollingImages };\n",
      "type": "registry:ui",
      "target": "components/sora-ui/catalog/infinite-scrolling-images.tsx"
    }
  ],
  "meta": {
    "keywords": [
      "infinite",
      "scrolling",
      "images",
      "stack",
      "motion",
      "wheel",
      "touch"
    ]
  },
  "type": "registry:ui"
}
