{ "name": "infinite-parallax-gallery", "type": "registry:ui", "registryDependencies": [], "dependencies": [ "three" ], "devDependencies": [], "tailwind": {}, "cssVars": { "light": {}, "dark": {} }, "files": [ { "path": "infinite-parallax-gallery.tsx", "content": "'use client';\n\nimport React, { useEffect, useRef, useState } from 'react';\nimport * as THREE from 'three';\nimport { cn } from '@/lib/utils';\n\nexport interface InfiniteParallaxGalleryProps extends React.HTMLAttributes {\n /** Array of image URLs to load in the gallery */\n images: string[];\n /** Number of depth layers for the parallax effect. Default: 5 */\n depthLayers?: number;\n /** Number of images to instantiate per layer. Default: 10 */\n imagesPerLayer?: number;\n /** Max width of an image sprite. Default: 160 */\n maxImageWidth?: number;\n /** Max height of an image sprite. Default: 160 */\n maxImageHeight?: number;\n /** Optional overlay to display on top of the gallery (e.g., attribution text) */\n overlayText?: React.ReactNode;\n /** Whether to show the loading indicator. Default: true */\n showLoading?: boolean;\n}\n\nconst LAYER_CONFIG = [\n { scale: 1.5, speed: 80, opacity: 1.0 },\n { scale: 1.0, speed: 40, opacity: 0.85 },\n { scale: 0.8, speed: 30, opacity: 0.7 },\n { scale: 0.6, speed: 20, opacity: 0.55 },\n { scale: 0.5, speed: 15, opacity: 0.4 },\n];\n\nfunction shuffleArray(array: T[]): T[] {\n const newArray = [...array];\n for (let i = newArray.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n [newArray[i], newArray[j]] = [newArray[j], newArray[i]];\n }\n return newArray;\n}\n\nfunction rand(min: number, max: number) {\n return Math.random() * (max - min) + min;\n}\n\nfunction createFallbackTexture(\n layer: number,\n maxWidth: number,\n maxHeight: number\n) {\n const c = document.createElement('canvas');\n c.width = maxWidth;\n c.height = maxHeight;\n const ctx = c.getContext('2d');\n if (ctx) {\n ctx.fillStyle = ['#4a6572', '#344955', '#232f34', '#1c2529', '#0f1518'][\n Math.min(layer, 4)\n ];\n ctx.fillRect(0, 0, c.width, c.height);\n }\n return new THREE.CanvasTexture(c);\n}\n\nexport const InfiniteParallaxGallery = React.forwardRef<\n HTMLDivElement,\n InfiniteParallaxGalleryProps\n>(\n (\n {\n images,\n depthLayers = 5,\n imagesPerLayer = 10,\n maxImageWidth = 160,\n maxImageHeight = 160,\n overlayText,\n showLoading = true,\n className,\n ...props\n },\n ref\n ) => {\n const containerRef = useRef(null);\n const [loadingProgress, setLoadingProgress] = useState(0);\n const [isLoaded, setIsLoaded] = useState(false);\n\n useEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n\n const scene = new THREE.Scene();\n const renderer = new THREE.WebGLRenderer({\n antialias: true,\n alpha: true,\n powerPreference: 'high-performance',\n });\n\n renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));\n renderer.setClearColor(0x000000, 0);\n container.appendChild(renderer.domElement);\n\n let camera: THREE.OrthographicCamera;\n let layers: THREE.Sprite[][] = [];\n const textures: THREE.Texture[] = [];\n\n let dragActive = false;\n let lastX = 0;\n let dragVelocity = 0;\n let speedFactor = 1;\n let lastTime = 0;\n let animationFrameId: number;\n\n let shuffledImages: string[] = [];\n let currentImageIndex = 0;\n\n const TOTAL = depthLayers * imagesPerLayer;\n\n function getNextRandomImage() {\n if (currentImageIndex >= shuffledImages.length) {\n shuffledImages = shuffleArray(images);\n currentImageIndex = 0;\n }\n const image = shuffledImages[currentImageIndex];\n currentImageIndex++;\n return image;\n }\n\n function resize() {\n if (!container) return;\n const w = container.clientWidth;\n const h = container.clientHeight;\n renderer.setSize(w, h);\n\n if (!camera) {\n camera = new THREE.OrthographicCamera(0, w, h, 0, -1000, 1000);\n camera.position.z = 10;\n } else {\n camera.right = w;\n camera.top = h;\n camera.updateProjectionMatrix();\n }\n\n for (const layer of layers) {\n if (!layer) continue;\n for (const s of layer) {\n scene.remove(s);\n if (s.material.map) s.material.map.dispose();\n s.material.dispose();\n s.geometry.dispose();\n }\n }\n\n layers = [];\n for (let l = 0; l < depthLayers; l++) layers[l] = [];\n if (textures.length === TOTAL) fillViewport();\n }\n\n const handleResize = () => resize();\n window.addEventListener('resize', handleResize);\n\n // Initialize\n for (let l = 0; l < depthLayers; l++) layers[l] = [];\n resize();\n\n const loader = new THREE.TextureLoader();\n loader.crossOrigin = 'anonymous';\n\n let loadedCount = 0;\n\n function onLoaded(tex: THREE.Texture) {\n textures.push(tex);\n loadedCount++;\n setLoadingProgress(Math.round((loadedCount / TOTAL) * 100));\n\n if (loadedCount === TOTAL) {\n initSprites();\n }\n }\n\n function loadAll() {\n shuffledImages = shuffleArray(images);\n currentImageIndex = 0;\n for (let l = 0; l < depthLayers; l++) {\n for (let i = 0; i < imagesPerLayer; i++) {\n const path = getNextRandomImage();\n loader.load(\n path,\n (tex) => onLoaded(tex),\n undefined,\n () =>\n onLoaded(\n createFallbackTexture(l, maxImageWidth, maxImageHeight)\n )\n );\n }\n }\n }\n\n function initSprites() {\n fillViewport();\n setIsLoaded(true);\n lastTime = performance.now();\n animate();\n }\n\n function addSprite(layerIndex: number, startX: number) {\n if (!container) return;\n const cfg = LAYER_CONFIG[layerIndex % LAYER_CONFIG.length];\n const texIndex = Math.floor(Math.random() * textures.length);\n const texture =\n textures[texIndex] ||\n createFallbackTexture(layerIndex, maxImageWidth, maxImageHeight);\n\n const mat = new THREE.SpriteMaterial({\n map: texture,\n transparent: true,\n opacity: cfg.opacity,\n });\n\n const sprite = new THREE.Sprite(mat);\n const imageObj = texture.image as\n { width?: number; height?: number } | undefined;\n let width = maxImageWidth;\n let height = maxImageHeight;\n\n if (imageObj && imageObj.width && imageObj.height) {\n const ratio = imageObj.width / imageObj.height;\n if (ratio > 1) {\n width = maxImageWidth;\n height = maxImageWidth / ratio;\n } else {\n height = maxImageHeight;\n width = maxImageHeight * ratio;\n }\n }\n\n const sizeVar = rand(0.85, 1.15);\n const w = width * cfg.scale * sizeVar;\n const h = height * cfg.scale * sizeVar;\n const spacing = w * rand(0.5, 0.9);\n\n sprite.scale.set(w, h, 1);\n sprite.position.set(\n startX + w / 2 + spacing,\n rand(h / 2, container.clientHeight - h / 2),\n -layerIndex * 50\n );\n\n const speedVariation = rand(0.45, 1.15);\n sprite.userData = {\n speed: cfg.speed * speedVariation,\n width: w,\n height: h,\n seed: rand(0, 1000),\n baseY: sprite.position.y,\n opacity: cfg.opacity,\n };\n\n layers[layerIndex].push(sprite);\n scene.add(sprite);\n return sprite;\n }\n\n function cleanupSprites() {\n if (!container) return;\n const w = container.clientWidth;\n const bufferZone = w * 0.5;\n\n for (let l = 0; l < depthLayers; l++) {\n if (!layers[l] || layers[l].length === 0) continue;\n const sprites = layers[l];\n const maxSprites = imagesPerLayer + 3;\n\n if (sprites.length > maxSprites) {\n for (let i = sprites.length - 1; i >= 0; i--) {\n const s = sprites[i];\n const ud = s.userData;\n let shouldRemove = false;\n\n if (speedFactor > 0) {\n shouldRemove = s.position.x - ud.width / 2 > w + bufferZone;\n } else if (speedFactor < 0) {\n shouldRemove = s.position.x + ud.width / 2 < -bufferZone;\n }\n\n if (shouldRemove) {\n scene.remove(s);\n if (s.material.map) s.material.map.dispose();\n s.material.dispose();\n sprites.splice(i, 1);\n if (sprites.length <= maxSprites) break;\n }\n }\n }\n }\n }\n\n function fillViewport() {\n if (!container) return;\n const w = container.clientWidth;\n for (let l = 0; l < depthLayers; l++) {\n let sprites = layers[l];\n if (!sprites) continue;\n\n let rightMost =\n sprites.length > 0\n ? Math.max(\n ...sprites.map((s) => s.position.x + s.userData.width / 2)\n )\n : -container.clientWidth * 1.2;\n\n while (rightMost < w) {\n addSprite(l, rightMost);\n sprites = layers[l];\n rightMost = Math.max(\n ...sprites.map((s) => s.position.x + s.userData.width / 2)\n );\n }\n }\n }\n\n function animate() {\n const now = performance.now();\n const dt = Math.min(40, now - lastTime) / 1000;\n lastTime = now;\n if (!container) return;\n const w = container.clientWidth;\n\n dragVelocity *= 0.92;\n speedFactor =\n dragVelocity !== 0 ? Math.sign(dragVelocity) : speedFactor;\n\n if (Math.random() < 0.01) {\n cleanupSprites();\n }\n\n for (const sprites of layers) {\n if (!sprites || !sprites.length) continue;\n for (const s of sprites) {\n const ud = s.userData;\n s.position.x += ud.speed * speedFactor * dt;\n\n if (speedFactor > 0 && s.position.x - ud.width / 2 > w) {\n s.position.x = -ud.width / 2 - rand(0, ud.width);\n } else if (speedFactor < 0 && s.position.x + ud.width / 2 < 0) {\n s.position.x = w + ud.width / 2 + rand(0, ud.width);\n }\n\n const pulse = 1 + Math.sin(now * 0.001 + ud.seed) * 0.015;\n s.scale.x = ud.width * pulse;\n s.scale.y = ud.height * pulse;\n s.position.y = ud.baseY + Math.sin(now * 0.001 + ud.seed) * 5;\n s.material.opacity = ud.opacity;\n }\n }\n\n renderer.render(scene, camera);\n animationFrameId = requestAnimationFrame(animate);\n }\n\n loadAll();\n\n // Event Listeners\n function getX(e: MouseEvent | TouchEvent) {\n return 'touches' in e\n ? e.touches[0].clientX\n : (e as MouseEvent).clientX;\n }\n\n const onMouseDown = (e: MouseEvent | TouchEvent) => {\n dragActive = true;\n lastX = getX(e);\n };\n\n const onMouseMove = (e: MouseEvent | TouchEvent) => {\n if (!dragActive) return;\n const x = getX(e);\n const dx = x - lastX;\n lastX = x;\n dragVelocity = dx * 0.02;\n };\n\n const onMouseUp = () => {\n dragActive = false;\n };\n\n const onWheel = (e: WheelEvent) => {\n e.preventDefault();\n const wheelDelta = Math.sign(e.deltaY);\n const direction = wheelDelta > 0 ? 1 : -1;\n const acceleration = 0.8;\n\n speedFactor = direction * (Math.abs(speedFactor) + acceleration);\n const maxSpeed = 5;\n const sign = Math.sign(speedFactor);\n const absSpeed = Math.min(maxSpeed, Math.abs(speedFactor));\n\n speedFactor = sign * absSpeed;\n dragVelocity = 0;\n cleanupSprites();\n };\n\n container.addEventListener('mousedown', onMouseDown);\n container.addEventListener('mousemove', onMouseMove);\n window.addEventListener('mouseup', onMouseUp);\n container.addEventListener('touchstart', onMouseDown, { passive: true });\n container.addEventListener('touchmove', onMouseMove, { passive: true });\n window.addEventListener('touchend', onMouseUp);\n container.addEventListener('wheel', onWheel, { passive: false });\n\n return () => {\n cancelAnimationFrame(animationFrameId);\n window.removeEventListener('resize', handleResize);\n window.removeEventListener('mouseup', onMouseUp);\n window.removeEventListener('touchend', onMouseUp);\n\n if (container) {\n container.removeEventListener('mousedown', onMouseDown);\n container.removeEventListener('mousemove', onMouseMove);\n container.removeEventListener('touchstart', onMouseDown);\n container.removeEventListener('touchmove', onMouseMove);\n container.removeEventListener('wheel', onWheel);\n\n if (container.contains(renderer.domElement)) {\n container.removeChild(renderer.domElement);\n }\n }\n\n textures.forEach((t) => t.dispose());\n for (const layer of layers) {\n for (const s of layer) {\n if (s.material.map) s.material.map.dispose();\n s.material.dispose();\n s.geometry.dispose();\n }\n }\n renderer.dispose();\n renderer.forceContextLoss();\n };\n }, [images, depthLayers, imagesPerLayer, maxImageWidth, maxImageHeight]);\n\n return (\n \n \n\n {showLoading && !isLoaded && (\n
\n
\n Loading {loadingProgress}%\n
\n
\n )}\n\n {isLoaded && overlayText && (\n
\n {overlayText}\n
\n )}\n \n );\n }\n);\n\nInfiniteParallaxGallery.displayName = 'InfiniteParallaxGallery';\n", "type": "registry:ui" }, { "path": "infinite-parallax-gallery.tsx", "content": "'use client';\n\nimport React, { useEffect, useRef, useState } from 'react';\nimport * as THREE from 'three';\nimport { cn } from '@/lib/utils';\n\nexport interface InfiniteParallaxGalleryProps extends React.HTMLAttributes {\n /** Array of image URLs to load in the gallery */\n images: string[];\n /** Number of depth layers for the parallax effect. Default: 5 */\n depthLayers?: number;\n /** Number of images to instantiate per layer. Default: 10 */\n imagesPerLayer?: number;\n /** Max width of an image sprite. Default: 160 */\n maxImageWidth?: number;\n /** Max height of an image sprite. Default: 160 */\n maxImageHeight?: number;\n /** Optional overlay to display on top of the gallery (e.g., attribution text) */\n overlayText?: React.ReactNode;\n /** Whether to show the loading indicator. Default: true */\n showLoading?: boolean;\n}\n\nconst LAYER_CONFIG = [\n { scale: 1.5, speed: 80, opacity: 1.0 },\n { scale: 1.0, speed: 40, opacity: 0.85 },\n { scale: 0.8, speed: 30, opacity: 0.7 },\n { scale: 0.6, speed: 20, opacity: 0.55 },\n { scale: 0.5, speed: 15, opacity: 0.4 },\n];\n\nfunction shuffleArray(array: T[]): T[] {\n const newArray = [...array];\n for (let i = newArray.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n [newArray[i], newArray[j]] = [newArray[j], newArray[i]];\n }\n return newArray;\n}\n\nfunction rand(min: number, max: number) {\n return Math.random() * (max - min) + min;\n}\n\nfunction createFallbackTexture(\n layer: number,\n maxWidth: number,\n maxHeight: number\n) {\n const c = document.createElement('canvas');\n c.width = maxWidth;\n c.height = maxHeight;\n const ctx = c.getContext('2d');\n if (ctx) {\n ctx.fillStyle = ['#4a6572', '#344955', '#232f34', '#1c2529', '#0f1518'][\n Math.min(layer, 4)\n ];\n ctx.fillRect(0, 0, c.width, c.height);\n }\n return new THREE.CanvasTexture(c);\n}\n\nexport const InfiniteParallaxGallery = React.forwardRef<\n HTMLDivElement,\n InfiniteParallaxGalleryProps\n>(\n (\n {\n images,\n depthLayers = 5,\n imagesPerLayer = 10,\n maxImageWidth = 160,\n maxImageHeight = 160,\n overlayText,\n showLoading = true,\n className,\n ...props\n },\n ref\n ) => {\n const containerRef = useRef(null);\n const [loadingProgress, setLoadingProgress] = useState(0);\n const [isLoaded, setIsLoaded] = useState(false);\n\n useEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n\n const scene = new THREE.Scene();\n const renderer = new THREE.WebGLRenderer({\n antialias: true,\n alpha: true,\n powerPreference: 'high-performance',\n });\n\n renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));\n renderer.setClearColor(0x000000, 0);\n container.appendChild(renderer.domElement);\n\n let camera: THREE.OrthographicCamera;\n let layers: THREE.Sprite[][] = [];\n const textures: THREE.Texture[] = [];\n\n let dragActive = false;\n let lastX = 0;\n let dragVelocity = 0;\n let speedFactor = 1;\n let lastTime = 0;\n let animationFrameId: number;\n\n let shuffledImages: string[] = [];\n let currentImageIndex = 0;\n\n const TOTAL = depthLayers * imagesPerLayer;\n\n function getNextRandomImage() {\n if (currentImageIndex >= shuffledImages.length) {\n shuffledImages = shuffleArray(images);\n currentImageIndex = 0;\n }\n const image = shuffledImages[currentImageIndex];\n currentImageIndex++;\n return image;\n }\n\n function resize() {\n if (!container) return;\n const w = container.clientWidth;\n const h = container.clientHeight;\n renderer.setSize(w, h);\n\n if (!camera) {\n camera = new THREE.OrthographicCamera(0, w, h, 0, -1000, 1000);\n camera.position.z = 10;\n } else {\n camera.right = w;\n camera.top = h;\n camera.updateProjectionMatrix();\n }\n\n for (const layer of layers) {\n if (!layer) continue;\n for (const s of layer) {\n scene.remove(s);\n if (s.material.map) s.material.map.dispose();\n s.material.dispose();\n s.geometry.dispose();\n }\n }\n\n layers = [];\n for (let l = 0; l < depthLayers; l++) layers[l] = [];\n if (textures.length === TOTAL) fillViewport();\n }\n\n const handleResize = () => resize();\n window.addEventListener('resize', handleResize);\n\n // Initialize\n for (let l = 0; l < depthLayers; l++) layers[l] = [];\n resize();\n\n const loader = new THREE.TextureLoader();\n loader.crossOrigin = 'anonymous';\n\n let loadedCount = 0;\n\n function onLoaded(tex: THREE.Texture) {\n textures.push(tex);\n loadedCount++;\n setLoadingProgress(Math.round((loadedCount / TOTAL) * 100));\n\n if (loadedCount === TOTAL) {\n initSprites();\n }\n }\n\n function loadAll() {\n shuffledImages = shuffleArray(images);\n currentImageIndex = 0;\n for (let l = 0; l < depthLayers; l++) {\n for (let i = 0; i < imagesPerLayer; i++) {\n const path = getNextRandomImage();\n loader.load(\n path,\n (tex) => onLoaded(tex),\n undefined,\n () =>\n onLoaded(\n createFallbackTexture(l, maxImageWidth, maxImageHeight)\n )\n );\n }\n }\n }\n\n function initSprites() {\n fillViewport();\n setIsLoaded(true);\n lastTime = performance.now();\n animate();\n }\n\n function addSprite(layerIndex: number, startX: number) {\n if (!container) return;\n const cfg = LAYER_CONFIG[layerIndex % LAYER_CONFIG.length];\n const texIndex = Math.floor(Math.random() * textures.length);\n const texture =\n textures[texIndex] ||\n createFallbackTexture(layerIndex, maxImageWidth, maxImageHeight);\n\n const mat = new THREE.SpriteMaterial({\n map: texture,\n transparent: true,\n opacity: cfg.opacity,\n });\n\n const sprite = new THREE.Sprite(mat);\n const imageObj = texture.image as\n { width?: number; height?: number } | undefined;\n let width = maxImageWidth;\n let height = maxImageHeight;\n\n if (imageObj && imageObj.width && imageObj.height) {\n const ratio = imageObj.width / imageObj.height;\n if (ratio > 1) {\n width = maxImageWidth;\n height = maxImageWidth / ratio;\n } else {\n height = maxImageHeight;\n width = maxImageHeight * ratio;\n }\n }\n\n const sizeVar = rand(0.85, 1.15);\n const w = width * cfg.scale * sizeVar;\n const h = height * cfg.scale * sizeVar;\n const spacing = w * rand(0.5, 0.9);\n\n sprite.scale.set(w, h, 1);\n sprite.position.set(\n startX + w / 2 + spacing,\n rand(h / 2, container.clientHeight - h / 2),\n -layerIndex * 50\n );\n\n const speedVariation = rand(0.45, 1.15);\n sprite.userData = {\n speed: cfg.speed * speedVariation,\n width: w,\n height: h,\n seed: rand(0, 1000),\n baseY: sprite.position.y,\n opacity: cfg.opacity,\n };\n\n layers[layerIndex].push(sprite);\n scene.add(sprite);\n return sprite;\n }\n\n function cleanupSprites() {\n if (!container) return;\n const w = container.clientWidth;\n const bufferZone = w * 0.5;\n\n for (let l = 0; l < depthLayers; l++) {\n if (!layers[l] || layers[l].length === 0) continue;\n const sprites = layers[l];\n const maxSprites = imagesPerLayer + 3;\n\n if (sprites.length > maxSprites) {\n for (let i = sprites.length - 1; i >= 0; i--) {\n const s = sprites[i];\n const ud = s.userData;\n let shouldRemove = false;\n\n if (speedFactor > 0) {\n shouldRemove = s.position.x - ud.width / 2 > w + bufferZone;\n } else if (speedFactor < 0) {\n shouldRemove = s.position.x + ud.width / 2 < -bufferZone;\n }\n\n if (shouldRemove) {\n scene.remove(s);\n if (s.material.map) s.material.map.dispose();\n s.material.dispose();\n sprites.splice(i, 1);\n if (sprites.length <= maxSprites) break;\n }\n }\n }\n }\n }\n\n function fillViewport() {\n if (!container) return;\n const w = container.clientWidth;\n for (let l = 0; l < depthLayers; l++) {\n let sprites = layers[l];\n if (!sprites) continue;\n\n let rightMost =\n sprites.length > 0\n ? Math.max(\n ...sprites.map((s) => s.position.x + s.userData.width / 2)\n )\n : -container.clientWidth * 1.2;\n\n while (rightMost < w) {\n addSprite(l, rightMost);\n sprites = layers[l];\n rightMost = Math.max(\n ...sprites.map((s) => s.position.x + s.userData.width / 2)\n );\n }\n }\n }\n\n function animate() {\n const now = performance.now();\n const dt = Math.min(40, now - lastTime) / 1000;\n lastTime = now;\n if (!container) return;\n const w = container.clientWidth;\n\n dragVelocity *= 0.92;\n speedFactor =\n dragVelocity !== 0 ? Math.sign(dragVelocity) : speedFactor;\n\n if (Math.random() < 0.01) {\n cleanupSprites();\n }\n\n for (const sprites of layers) {\n if (!sprites || !sprites.length) continue;\n for (const s of sprites) {\n const ud = s.userData;\n s.position.x += ud.speed * speedFactor * dt;\n\n if (speedFactor > 0 && s.position.x - ud.width / 2 > w) {\n s.position.x = -ud.width / 2 - rand(0, ud.width);\n } else if (speedFactor < 0 && s.position.x + ud.width / 2 < 0) {\n s.position.x = w + ud.width / 2 + rand(0, ud.width);\n }\n\n const pulse = 1 + Math.sin(now * 0.001 + ud.seed) * 0.015;\n s.scale.x = ud.width * pulse;\n s.scale.y = ud.height * pulse;\n s.position.y = ud.baseY + Math.sin(now * 0.001 + ud.seed) * 5;\n s.material.opacity = ud.opacity;\n }\n }\n\n renderer.render(scene, camera);\n animationFrameId = requestAnimationFrame(animate);\n }\n\n loadAll();\n\n // Event Listeners\n function getX(e: MouseEvent | TouchEvent) {\n return 'touches' in e\n ? e.touches[0].clientX\n : (e as MouseEvent).clientX;\n }\n\n const onMouseDown = (e: MouseEvent | TouchEvent) => {\n dragActive = true;\n lastX = getX(e);\n };\n\n const onMouseMove = (e: MouseEvent | TouchEvent) => {\n if (!dragActive) return;\n const x = getX(e);\n const dx = x - lastX;\n lastX = x;\n dragVelocity = dx * 0.02;\n };\n\n const onMouseUp = () => {\n dragActive = false;\n };\n\n const onWheel = (e: WheelEvent) => {\n e.preventDefault();\n const wheelDelta = Math.sign(e.deltaY);\n const direction = wheelDelta > 0 ? 1 : -1;\n const acceleration = 0.8;\n\n speedFactor = direction * (Math.abs(speedFactor) + acceleration);\n const maxSpeed = 5;\n const sign = Math.sign(speedFactor);\n const absSpeed = Math.min(maxSpeed, Math.abs(speedFactor));\n\n speedFactor = sign * absSpeed;\n dragVelocity = 0;\n cleanupSprites();\n };\n\n container.addEventListener('mousedown', onMouseDown);\n container.addEventListener('mousemove', onMouseMove);\n window.addEventListener('mouseup', onMouseUp);\n container.addEventListener('touchstart', onMouseDown, { passive: true });\n container.addEventListener('touchmove', onMouseMove, { passive: true });\n window.addEventListener('touchend', onMouseUp);\n container.addEventListener('wheel', onWheel, { passive: false });\n\n return () => {\n cancelAnimationFrame(animationFrameId);\n window.removeEventListener('resize', handleResize);\n window.removeEventListener('mouseup', onMouseUp);\n window.removeEventListener('touchend', onMouseUp);\n\n if (container) {\n container.removeEventListener('mousedown', onMouseDown);\n container.removeEventListener('mousemove', onMouseMove);\n container.removeEventListener('touchstart', onMouseDown);\n container.removeEventListener('touchmove', onMouseMove);\n container.removeEventListener('wheel', onWheel);\n\n if (container.contains(renderer.domElement)) {\n container.removeChild(renderer.domElement);\n }\n }\n\n textures.forEach((t) => t.dispose());\n for (const layer of layers) {\n for (const s of layer) {\n if (s.material.map) s.material.map.dispose();\n s.material.dispose();\n s.geometry.dispose();\n }\n }\n renderer.dispose();\n renderer.forceContextLoss();\n };\n }, [images, depthLayers, imagesPerLayer, maxImageWidth, maxImageHeight]);\n\n return (\n \n \n\n {showLoading && !isLoaded && (\n
\n
\n Loading {loadingProgress}%\n
\n
\n )}\n\n {isLoaded && overlayText && (\n
\n {overlayText}\n
\n )}\n \n );\n }\n);\n\nInfiniteParallaxGallery.displayName = 'InfiniteParallaxGallery';\n", "type": "registry:ui" } ] }