{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "InfiniteSpiral-JS-TW",
	"title": "InfiniteSpiral",
	"description": "An endlessly looping 3D helix of images with customizable motion, depth, spacing and interaction.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "InfiniteSpiral/InfiniteSpiral.jsx",
			"content": "'use client';\n\nimport { useEffect, useMemo, useRef } from 'react';\n\nconst clamp = (value, min, max) => Math.min(Math.max(value, min), max);\nconst modulo = (value, divisor) => ((value % divisor) + divisor) % divisor;\nconst smoothstep = (min, max, value) => {\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}) => {\n  const rootRef = useRef(null);\n  const cardRefs = useRef([]);\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(\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;\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 => {\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 dragEnabled = animationMode === 'drag' || animationMode === 'all';\n\n  const stopDragging = event => {\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  return (\n    <div\n      ref={rootRef}\n      className={`relative isolate h-full min-h-80 w-full overflow-hidden ${className}`}\n      style={{\n        perspective: `${perspective}px`,\n        '--spiral-width': `${cardWidth}px`,\n        '--spiral-height': `${cardHeight}px`,\n        '--spiral-radius': `${cardRadius}px`,\n        cursor: dragEnabled ? 'grab' : 'default',\n        touchAction: dragEnabled ? 'pan-x' : 'auto',\n        userSelect: dragEnabled ? 'none' : 'auto'\n      }}\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=\"absolute inset-0 [transform-style:preserve-3d]\" role=\"list\" aria-label=\"Infinite spiral gallery\">\n        {normalizedItems.map((item, index) => {\n          const Card = item.href ? 'a' : 'div';\n          return (\n            <Card\n              key={item.id ?? `${item.src}-${index}`}\n              ref={node => {\n                cardRefs.current[index] = node;\n              }}\n              className=\"absolute left-1/2 top-1/2 block h-[var(--spiral-height)] w-[var(--spiral-width)] overflow-hidden rounded-[var(--spiral-radius)] border border-white/25 bg-white/10 shadow-[0_14px_38px_rgba(8,6,18,0.2)] [backface-visibility:hidden] [transform-style:preserve-3d] [will-change:transform,opacity,filter] motion-reduce:transition-none\"\n              style={{ width: cardWidth, height: cardHeight, borderRadius: cardRadius }}\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              <img\n                className=\"absolute inset-0 block h-full w-full select-none object-center\"\n                src={item.src}\n                alt={item.alt}\n                loading={index < 6 ? 'eager' : 'lazy'}\n                draggable={false}\n                style={{\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            </Card>\n          );\n        })}\n      </div>\n    </div>\n  );\n};\n\nexport default InfiniteSpiral;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": []
}