{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "InfiniteSpiral-TS-CSS",
	"title": "InfiniteSpiral",
	"description": "An endlessly looping 3D helix of images with customizable motion, depth, spacing and interaction.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:file",
			"path": "InfiniteSpiral.css",
			"target": "@components/InfiniteSpiral.css",
			"content": ".infinite-spiral {\n  position: relative;\n  width: 100%;\n  height: 100%;\n  min-height: 320px;\n  overflow: hidden;\n  isolation: isolate;\n}\n\n.infinite-spiral__stage {\n  position: absolute;\n  inset: 0;\n  transform-style: preserve-3d;\n}\n\n.infinite-spiral__item {\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  display: block;\n  width: var(--infinite-spiral-card-width);\n  height: var(--infinite-spiral-card-height);\n  overflow: hidden;\n  border: 1px solid rgb(255 255 255 / 0.24);\n  border-radius: var(--infinite-spiral-card-radius);\n  background: rgb(255 255 255 / 0.08);\n  box-shadow: 0 14px 38px rgb(8 6 18 / 0.2);\n  transform-style: preserve-3d;\n  backface-visibility: hidden;\n  will-change: transform, opacity, filter;\n  text-decoration: none;\n}\n\n.infinite-spiral__image {\n  position: absolute;\n  inset: 0;\n  display: block;\n  width: 100%;\n  height: 100%;\n  object-position: center;\n  user-select: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .infinite-spiral__item {\n    transition: none;\n  }\n}\n"
		},
		{
			"type": "registry:component",
			"path": "InfiniteSpiral.tsx",
			"content": "'use client';\n\nimport { useEffect, useMemo, useRef, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react';\nimport './InfiniteSpiral.css';\n\nexport interface InfiniteSpiralItem {\n  id?: string | number;\n  src: string;\n  alt?: string;\n  href?: string;\n  target?: '_blank' | '_self' | '_parent' | '_top';\n  label?: string;\n}\n\nexport interface InfiniteSpiralProps {\n  items?: Array<string | InfiniteSpiralItem>;\n  speed?: number;\n  direction?: 'up' | 'down';\n  animationMode?: 'auto' | 'drag' | 'scroll' | 'all';\n  radius?: number;\n  cardWidth?: number;\n  cardHeight?: number;\n  verticalSpacing?: number;\n  perspective?: number;\n  cardsPerTurn?: number;\n  rotation?: number;\n  cardTilt?: number;\n  cardRadius?: number;\n  centerScale?: number;\n  edgeFade?: number;\n  edgeBlur?: number;\n  pauseOnHover?: boolean;\n  imageFit?: CSSProperties['objectFit'];\n  grayscale?: number;\n  className?: string;\n}\n\ntype NormalizedItem = InfiniteSpiralItem & { alt: string };\n\nconst clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);\nconst modulo = (value: number, divisor: number) => ((value % divisor) + divisor) % divisor;\nconst smoothstep = (min: number, max: number, value: number) => {\n  const x = clamp((value - min) / (max - min || 1), 0, 1);\n  return x * x * (3 - 2 * x);\n};\n\nconst InfiniteSpiral = ({\n  items = [],\n  speed = 0.55,\n  direction = 'up',\n  animationMode = 'auto',\n  radius = 170,\n  cardWidth = 100,\n  cardHeight = 100,\n  verticalSpacing = 60,\n  perspective = 1000,\n  cardsPerTurn = 7,\n  rotation = 0,\n  cardTilt = 0,\n  cardRadius = 10,\n  centerScale = 1.2,\n  edgeFade = 0.3,\n  edgeBlur = 6,\n  pauseOnHover = true,\n  imageFit = 'cover',\n  grayscale = 0,\n  className = ''\n}: InfiniteSpiralProps) => {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const cardRefs = useRef<Array<HTMLAnchorElement | HTMLDivElement | null>>([]);\n  const progressRef = useRef(0);\n  const targetProgressRef = useRef(0);\n  const autoSpeedRef = useRef(0);\n  const hoveredRef = useRef(false);\n  const visibleRef = useRef(true);\n  const draggingRef = useRef(false);\n  const lastPointerYRef = useRef(0);\n  const dragMovedRef = useRef(false);\n\n  const normalizedItems = useMemo<NormalizedItem[]>(\n    () =>\n      items.map((item, index) =>\n        typeof item === 'string'\n          ? { src: item, alt: `Spiral image ${index + 1}` }\n          : { alt: `Spiral image ${index + 1}`, ...item }\n      ),\n    [items]\n  );\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root || normalizedItems.length === 0) return;\n\n    let frameId = 0;\n    let previousTime = performance.now();\n    let bounds = root.getBoundingClientRect();\n    const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');\n    const scrollEnabled = animationMode === 'scroll' || animationMode === 'all';\n    const scrollSpeedMultiplier = Math.max(speed, 0) / 0.55;\n    let lastScrollY = window.scrollY;\n    const resizeObserver = new ResizeObserver(() => {\n      bounds = root.getBoundingClientRect();\n    });\n    resizeObserver.observe(root);\n    const intersectionObserver = new IntersectionObserver(([entry]) => {\n      visibleRef.current = entry.isIntersecting;\n    });\n    intersectionObserver.observe(root);\n\n    const handleScroll = () => {\n      const nextScrollY = window.scrollY;\n      const scrollDelta = nextScrollY - lastScrollY;\n      lastScrollY = nextScrollY;\n      if (!scrollEnabled || !visibleRef.current || scrollDelta === 0) return;\n      targetProgressRef.current += clamp(\n        (scrollDelta * scrollSpeedMultiplier) / Math.max(verticalSpacing * 2, 1),\n        -1.5,\n        1.5\n      );\n    };\n    window.addEventListener('scroll', handleScroll, { passive: true });\n\n    const render = (time: number) => {\n      const delta = Math.min((time - previousTime) / 1000, 0.05);\n      previousTime = time;\n      const autoEnabled = animationMode === 'auto' || animationMode === 'all';\n      const motionPaused = draggingRef.current || (pauseOnHover && hoveredRef.current);\n      const directionMultiplier = direction === 'down' ? -1 : 1;\n      const desiredAutoSpeed =\n        autoEnabled && visibleRef.current && !reducedMotion.matches && !motionPaused\n          ? speed * directionMultiplier\n          : 0;\n      const speedBlend = 1 - Math.exp(-delta * 7);\n      autoSpeedRef.current += (desiredAutoSpeed - autoSpeedRef.current) * speedBlend;\n      targetProgressRef.current += autoSpeedRef.current * delta;\n\n      const followBlend = 1 - Math.exp(-delta * (draggingRef.current ? 22 : 11));\n      progressRef.current += (targetProgressRef.current - progressRef.current) * followBlend;\n\n      const count = normalizedItems.length;\n      const half = count / 2;\n      const width = Math.max(bounds.width, 1);\n      const height = Math.max(bounds.height, 1);\n      const fit = Math.min(1, width / (cardWidth * 2.8), height / (cardHeight * 2.35));\n      const responsiveRadius = Math.min(radius, Math.max(72, width * 0.36)) * fit;\n      const fadeStart = clamp(1 - edgeFade, 0, 0.98);\n      const turnSize = Math.max(cardsPerTurn, 1);\n\n      cardRefs.current.forEach((card, index) => {\n        if (!card) return;\n        const offset = modulo(index - progressRef.current + half, count) - half;\n        const edge = Math.min(Math.abs(offset) / Math.max(half, 1), 1);\n        const opacity = 1 - smoothstep(fadeStart, 1, edge);\n        const focus = 1 - Math.min(Math.abs(offset) / Math.max(turnSize * 0.65, 1), 1);\n        const scale = (1 + (centerScale - 1) * focus) * fit;\n        const angle = offset * (360 / turnSize) + rotation;\n        const angleRadians = (angle * Math.PI) / 180;\n        const x = Math.sin(angleRadians) * responsiveRadius;\n        const z = Math.cos(angleRadians) * responsiveRadius;\n        const depthScale = clamp(perspective / Math.max(perspective - z, 1), 0.72, 1.45);\n        const visualScale = scale * depthScale;\n        const depth = (z / Math.max(responsiveRadius, 1) + 1) / 2;\n        const blur = edgeBlur * smoothstep(0.35, 1, edge);\n        card.style.transform = `translate(-50%, -50%) translate3d(${x}px, ${offset * verticalSpacing * fit}px, 0) rotateZ(${cardTilt}deg) scale(${visualScale})`;\n        card.style.opacity = opacity.toFixed(3);\n        card.style.filter = blur > 0.01 ? `blur(${blur.toFixed(2)}px)` : 'none';\n        card.style.zIndex = String(Math.round(depth * 100000) + index);\n        card.style.pointerEvents = opacity > 0.25 ? 'auto' : 'none';\n      });\n      frameId = requestAnimationFrame(render);\n    };\n\n    frameId = requestAnimationFrame(render);\n    return () => {\n      cancelAnimationFrame(frameId);\n      resizeObserver.disconnect();\n      intersectionObserver.disconnect();\n      window.removeEventListener('scroll', handleScroll);\n    };\n  }, [\n    normalizedItems,\n    speed,\n    direction,\n    animationMode,\n    radius,\n    perspective,\n    cardWidth,\n    cardHeight,\n    verticalSpacing,\n    cardsPerTurn,\n    rotation,\n    cardTilt,\n    centerScale,\n    edgeFade,\n    edgeBlur,\n    pauseOnHover\n  ]);\n\n  const rootStyle = {\n    perspective: `${perspective}px`,\n    '--infinite-spiral-card-width': `${cardWidth}px`,\n    '--infinite-spiral-card-height': `${cardHeight}px`,\n    '--infinite-spiral-card-radius': `${cardRadius}px`,\n    cursor: animationMode === 'drag' || animationMode === 'all' ? 'grab' : 'default',\n    touchAction: animationMode === 'drag' || animationMode === 'all' ? 'pan-x' : 'auto',\n    userSelect: animationMode === 'drag' || animationMode === 'all' ? 'none' : 'auto'\n  } as CSSProperties;\n\n  const dragEnabled = animationMode === 'drag' || animationMode === 'all';\n\n  const stopDragging = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (!draggingRef.current) return;\n    draggingRef.current = false;\n    if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId);\n    }\n    event.currentTarget.style.cursor = dragEnabled ? 'grab' : 'default';\n  };\n\n  const setCardRef = (index: number) => (node: HTMLAnchorElement | HTMLDivElement | null) => {\n    cardRefs.current[index] = node;\n  };\n\n  const cardStyle: CSSProperties = { width: cardWidth, height: cardHeight, borderRadius: cardRadius };\n  const imageStyle: CSSProperties = {\n    width: cardWidth,\n    height: cardHeight,\n    maxWidth: 'none',\n    maxHeight: 'none',\n    objectFit: imageFit,\n    filter: `grayscale(${Math.min(1, Math.max(0, grayscale))})`\n  };\n\n  return (\n    <div\n      ref={rootRef}\n      className={`infinite-spiral ${className}`.trim()}\n      style={rootStyle}\n      onMouseEnter={() => {\n        hoveredRef.current = true;\n      }}\n      onMouseLeave={() => {\n        hoveredRef.current = false;\n      }}\n      onPointerDown={event => {\n        if (!dragEnabled || event.button !== 0) return;\n        draggingRef.current = true;\n        dragMovedRef.current = false;\n        lastPointerYRef.current = event.clientY;\n        targetProgressRef.current = progressRef.current;\n        event.currentTarget.setPointerCapture(event.pointerId);\n        event.currentTarget.style.cursor = 'grabbing';\n      }}\n      onPointerMove={event => {\n        if (!draggingRef.current) return;\n        const pointerDelta = event.clientY - lastPointerYRef.current;\n        lastPointerYRef.current = event.clientY;\n        if (Math.abs(pointerDelta) > 0.5) dragMovedRef.current = true;\n        targetProgressRef.current -= pointerDelta / Math.max(verticalSpacing, 1);\n      }}\n      onPointerUp={stopDragging}\n      onPointerCancel={stopDragging}\n      onClickCapture={event => {\n        if (!dragMovedRef.current) return;\n        event.preventDefault();\n        event.stopPropagation();\n        dragMovedRef.current = false;\n      }}\n    >\n      <div className=\"infinite-spiral__stage\" role=\"list\" aria-label=\"Infinite spiral gallery\">\n        {normalizedItems.map((item, index) => {\n          const content = (\n            <img\n              className=\"infinite-spiral__image\"\n              src={item.src}\n              alt={item.alt}\n              loading={index < 6 ? 'eager' : 'lazy'}\n              draggable={false}\n              style={imageStyle}\n            />\n          );\n\n          return item.href ? (\n            <a\n              key={item.id ?? `${item.src}-${index}`}\n              ref={setCardRef(index)}\n              className=\"infinite-spiral__item\"\n              style={cardStyle}\n              href={item.href}\n              target={item.target}\n              rel={item.target === '_blank' ? 'noreferrer' : undefined}\n              role=\"listitem\"\n              aria-label={item.label ?? item.alt}\n            >\n              {content}\n            </a>\n          ) : (\n            <div\n              key={item.id ?? `${item.src}-${index}`}\n              ref={setCardRef(index)}\n              className=\"infinite-spiral__item\"\n              style={cardStyle}\n              role=\"listitem\"\n              aria-label={item.label ?? item.alt}\n            >\n              {content}\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n};\n\nexport default InfiniteSpiral;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": []
}