{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "DriftWall-JS-CSS",
	"title": "DriftWall",
	"description": "An endless perspective wall of tiles drifting past, lifting on hover.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:file",
			"path": "DriftWall.css",
			"target": "@components/DriftWall.css",
			"content": ".drift-wall {\n  position: relative;\n  width: 100%;\n  height: 100%;\n  overflow: hidden;\n  perspective: var(--dw-perspective, 1200px);\n  perspective-origin: 50% 50%;\n  --dw-tile-w: 200px;\n  --dw-tile-h: 132px;\n  --dw-gap: 18px;\n  --dw-radius: 14px;\n  --dw-lift: 64px;\n  --dw-dim: 0.55;\n  --dw-gray: 0;\n  --dw-overlay: #060010;\n  --dw-edge: 40%;\n  -webkit-mask-image:\n    radial-gradient(ellipse 78% 82% at 50% 46%, #000 var(--dw-edge), transparent 100%),\n    linear-gradient(to top, #000 var(--dw-edge), transparent 100%);\n  -webkit-mask-composite: source-in;\n  mask-image:\n    radial-gradient(ellipse 78% 82% at 50% 46%, #000 var(--dw-edge), transparent 100%),\n    linear-gradient(to top, #000 var(--dw-edge), transparent 100%);\n  mask-composite: intersect;\n}\n\n.drift-wall__plane {\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  display: flex;\n  flex-direction: row;\n  transform-style: preserve-3d;\n  cursor: pointer;\n  transform-origin: 50% 50%;\n  will-change: transform;\n}\n\n.drift-wall__col {\n  position: relative;\n  width: calc(var(--dw-tile-w) + var(--dw-gap));\n  transform-style: preserve-3d;\n}\n\n.drift-wall__track {\n  display: flex;\n  flex-direction: column;\n  will-change: transform;\n  transform-style: preserve-3d;\n}\n\n.drift-wall__tile {\n  position: relative;\n  display: block;\n  width: 100%;\n  height: calc(var(--dw-tile-h) + var(--dw-gap));\n  flex: 0 0 auto;\n  outline: none;\n  transform-style: preserve-3d;\n}\n\n.drift-wall__inner {\n  position: absolute;\n  inset: calc(var(--dw-gap) / 2);\n  display: block;\n  border-radius: var(--dw-radius);\n  overflow: hidden;\n  background: #0b0b12;\n  opacity: var(--dw-dim);\n  transform: translateZ(0);\n  pointer-events: none;\n  transition:\n    transform 0.42s cubic-bezier(0.22, 1, 0.36, 1),\n    opacity 0.42s cubic-bezier(0.22, 1, 0.36, 1),\n    box-shadow 0.42s cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n.drift-wall__tile img {\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  display: block;\n  filter: grayscale(var(--dw-gray)) saturate(0.92);\n  transition: filter 0.42s cubic-bezier(0.22, 1, 0.36, 1);\n  user-select: none;\n  -webkit-user-drag: none;\n}\n\n.drift-wall__overlay {\n  position: absolute;\n  inset: 0;\n  background: var(--dw-overlay);\n  opacity: 0.42;\n  pointer-events: none;\n  transition: opacity 0.42s cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n.drift-wall__tile.is-active .drift-wall__inner,\n.drift-wall__tile:focus-visible .drift-wall__inner {\n  opacity: 1;\n  transform: translateZ(var(--dw-lift));\n  box-shadow: 0 24px 60px -18px rgba(0, 0, 0, 0.7);\n}\n\n.drift-wall__tile.is-active img,\n.drift-wall__tile:focus-visible img {\n  filter: grayscale(0) saturate(1.05);\n}\n\n.drift-wall__tile.is-active .drift-wall__overlay,\n.drift-wall__tile:focus-visible .drift-wall__overlay {\n  opacity: 0;\n}\n\n.drift-wall__tile:focus-visible .drift-wall__inner {\n  box-shadow:\n    0 24px 60px -18px rgba(0, 0, 0, 0.7),\n    0 0 0 2px rgba(255, 255, 255, 0.9);\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .drift-wall__plane,\n  .drift-wall__track {\n    will-change: auto;\n  }\n}\n"
		},
		{
			"type": "registry:component",
			"path": "DriftWall.jsx",
			"content": "import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport './DriftWall.css';\n\nconst DEFAULT_ITEMS = Array.from({ length: 15 }, (_, i) => {\n  const ids = [1015, 1025, 1039, 1043, 1044, 1050, 1062, 1069, 1074, 1080, 1084, 106, 110, 133, 164];\n  return {\n    image: `https://picsum.photos/id/${ids[i % ids.length]}/600/400`,\n    title: `Tile ${i + 1}`,\n    href: undefined\n  };\n});\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst columnFactor = (index, variance) => {\n  const pseudo = ((index * 0.6180339887 + 0.35) % 1) * 2 - 1;\n  return 1 + variance * pseudo;\n};\n\nconst DriftWall = ({\n  items = DEFAULT_ITEMS,\n  columns = 5,\n  tileWidth = 200,\n  tileHeight = 132,\n  gap = 18,\n  radius = 14,\n  tilt = 16,\n  turn = -14,\n  roll = 0,\n  perspective = 1200,\n  depth = 120,\n  speed = 42,\n  direction = 'up',\n  variance = 0.45,\n  parallax = 0.6,\n  pauseOnHover = false,\n  lift = 64,\n  fade = 0.6,\n  dim = 0.55,\n  grayscale = false,\n  overlayColor = '#060010',\n  className = '',\n  style\n}) => {\n  const containerRef = useRef(null);\n  const planeRef = useRef(null);\n  const trackRefs = useRef([]);\n  const rafRef = useRef(null);\n\n  const offsetsRef = useRef([]);\n  const velocitiesRef = useRef([]);\n  const hoveredColRef = useRef(-1);\n  const wallHoveredRef = useRef(false);\n  const pointerRef = useRef({ x: 0, y: 0 });\n  const pointerDampedRef = useRef({ x: 0, y: 0 });\n  const lastTsRef = useRef(null);\n\n  const [containerHeight, setContainerHeight] = useState(600);\n  const [activeId, setActiveId] = useState(null);\n  const activeIdRef = useRef(null);\n  const [reduced, setReduced] = useState(false);\n\n  useEffect(() => {\n    setReduced(prefersReducedMotion());\n    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');\n    const onChange = e => setReduced(e.matches);\n    mq.addEventListener('change', onChange);\n    return () => mq.removeEventListener('change', onChange);\n  }, []);\n\n  const columnItems = useMemo(() => {\n    const cols = Array.from({ length: columns }, () => []);\n    items.forEach((item, i) => cols[i % columns].push(item));\n    return cols.map(col => (col.length ? col : items.slice(0, 1)));\n  }, [items, columns]);\n\n  const columnMeta = useMemo(() => {\n    const unit = tileHeight + gap;\n    return columnItems.map(col => {\n      const copyHeight = Math.max(unit, col.length * unit);\n      const copies = Math.max(2, Math.ceil((containerHeight * 1.6) / copyHeight) + 1);\n      return { copyHeight, copies };\n    });\n  }, [columnItems, tileHeight, gap, containerHeight]);\n\n  useLayoutEffect(() => {\n    if (!containerRef.current) return;\n    const ro = new ResizeObserver(([entry]) => {\n      setContainerHeight(entry.contentRect.height || 600);\n    });\n    ro.observe(containerRef.current);\n    return () => ro.disconnect();\n  }, []);\n\n  const baseVelocities = useMemo(() => {\n    const dirSign = direction === 'up' ? 1 : -1;\n    return columnItems.map((_, c) => {\n      const altSign = c % 2 === 0 ? 1 : -1;\n      return speed * columnFactor(c, variance) * dirSign * altSign;\n    });\n  }, [columnItems, speed, direction, variance]);\n\n  useEffect(() => {\n    offsetsRef.current = columnMeta.map((meta, c) => meta.copyHeight * ((c * 0.37) % 1));\n    velocitiesRef.current = columnItems.map(() => 0);\n  }, [columnMeta, columnItems]);\n\n  const applyPlaneTransform = useCallback(\n    (px, py) => {\n      const plane = planeRef.current;\n      if (!plane) return;\n      plane.style.transform =\n        `translate(-50%, -50%) scale(1.18) ` +\n        `rotateX(${tilt + py}deg) rotateY(${turn + px}deg) rotateZ(${roll}deg) ` +\n        `translateZ(${-depth}px)`;\n    },\n    [tilt, turn, roll, depth]\n  );\n\n  useEffect(() => {\n    const animate = ts => {\n      if (lastTsRef.current === null) lastTsRef.current = ts;\n      const dt = Math.min(0.05, Math.max(0, ts - lastTsRef.current) / 1000);\n      lastTsRef.current = ts;\n\n      const maxTilt = parallax * 8;\n      const targetX = pointerRef.current.x * maxTilt;\n      const targetY = -pointerRef.current.y * maxTilt;\n      const damp = 1 - Math.exp(-dt / 0.12);\n      pointerDampedRef.current.x += (targetX - pointerDampedRef.current.x) * damp;\n      pointerDampedRef.current.y += (targetY - pointerDampedRef.current.y) * damp;\n      applyPlaneTransform(pointerDampedRef.current.x, pointerDampedRef.current.y);\n\n      if (!reduced) {\n        for (let c = 0; c < trackRefs.current.length; c++) {\n          const meta = columnMeta[c];\n          if (!meta) continue;\n          const paused = wallHoveredRef.current && pauseOnHover;\n          const factor = paused || hoveredColRef.current === c ? 0 : 1;\n          const target = baseVelocities[c] * factor;\n\n          const ease = 1 - Math.exp(-dt / (target === 0 ? 0.16 : 0.28));\n          velocitiesRef.current[c] += (target - velocitiesRef.current[c]) * ease;\n          let next = (offsetsRef.current[c] ?? 0) + velocitiesRef.current[c] * dt;\n          next = ((next % meta.copyHeight) + meta.copyHeight) % meta.copyHeight;\n          offsetsRef.current[c] = next;\n\n          const el = trackRefs.current[c];\n          if (el) el.style.transform = `translate3d(0, ${-next}px, 0)`;\n        }\n      } else {\n        for (let c = 0; c < trackRefs.current.length; c++) {\n          const el = trackRefs.current[c];\n          const meta = columnMeta[c];\n          if (el && meta) el.style.transform = `translate3d(0, ${-(offsetsRef.current[c] ?? 0)}px, 0)`;\n        }\n      }\n\n      rafRef.current = requestAnimationFrame(animate);\n    };\n\n    rafRef.current = requestAnimationFrame(animate);\n    return () => {\n      if (rafRef.current) cancelAnimationFrame(rafRef.current);\n      rafRef.current = null;\n      lastTsRef.current = null;\n    };\n  }, [baseVelocities, columnMeta, pauseOnHover, parallax, reduced, applyPlaneTransform]);\n\n  const activate = useCallback((id, index) => {\n    activeIdRef.current = id;\n    hoveredColRef.current = index;\n    setActiveId(id);\n  }, []);\n  const release = useCallback(() => {\n    activeIdRef.current = null;\n    hoveredColRef.current = -1;\n    setActiveId(null);\n  }, []);\n\n  const handlePointerMove = useCallback(\n    e => {\n      const rect = containerRef.current?.getBoundingClientRect();\n      if (!rect) return;\n      if (parallax > 0 && !reduced) {\n        pointerRef.current = {\n          x: (e.clientX - rect.left) / rect.width - 0.5,\n          y: (e.clientY - rect.top) / rect.height - 0.5\n        };\n      }\n      const hit = document.elementFromPoint(e.clientX, e.clientY);\n      const tile = hit && hit.closest ? hit.closest('[data-tile-id]') : null;\n      if (!tile) return;\n      const id = tile.dataset.tileId;\n      if (id === activeIdRef.current) return;\n      activeIdRef.current = id;\n      hoveredColRef.current = Number(tile.dataset.col);\n      setActiveId(id);\n    },\n    [parallax, reduced]\n  );\n\n  const handlePointerLeaveWall = useCallback(() => {\n    wallHoveredRef.current = false;\n    pointerRef.current = { x: 0, y: 0 };\n    release();\n  }, [release]);\n\n  const cssVars = useMemo(\n    () => ({\n      '--dw-tile-w': `${tileWidth}px`,\n      '--dw-tile-h': `${tileHeight}px`,\n      '--dw-gap': `${gap}px`,\n      '--dw-radius': `${radius}px`,\n      '--dw-perspective': `${perspective}px`,\n      '--dw-lift': `${lift}px`,\n      '--dw-dim': dim,\n      '--dw-gray': grayscale ? 1 : 0,\n      '--dw-overlay': overlayColor,\n      '--dw-edge': `${Math.max(0, (1 - fade) * 100)}%`,\n      ...style\n    }),\n    [tileWidth, tileHeight, gap, radius, perspective, lift, dim, grayscale, overlayColor, fade, style]\n  );\n\n  const renderTile = (item, id, colIndex) => {\n    const inner = (\n      <span className=\"drift-wall__inner\">\n        <img src={item.image} alt={item.title ?? ''} loading=\"lazy\" decoding=\"async\" draggable={false} />\n        <span className=\"drift-wall__overlay\" aria-hidden=\"true\" />\n      </span>\n    );\n    const commonProps = {\n      className: `drift-wall__tile${activeId === id ? ' is-active' : ''}`,\n      'data-tile-id': id,\n      'data-col': colIndex,\n      onFocus: () => activate(id, colIndex),\n      onBlur: release\n    };\n    if (item.href) {\n      return (\n        <a key={id} href={item.href} target=\"_blank\" rel=\"noreferrer noopener\" {...commonProps}>\n          {inner}\n        </a>\n      );\n    }\n    return (\n      <div key={id} tabIndex={0} role=\"button\" aria-label={item.title ?? 'tile'} {...commonProps}>\n        {inner}\n      </div>\n    );\n  };\n\n  const rootClass = ['drift-wall', reduced ? 'drift-wall--reduced' : '', className].filter(Boolean).join(' ');\n\n  return (\n    <div\n      ref={containerRef}\n      className={rootClass}\n      style={cssVars}\n      onPointerMove={handlePointerMove}\n      onPointerEnter={() => {\n        wallHoveredRef.current = true;\n      }}\n      onPointerLeave={handlePointerLeaveWall}\n      role=\"group\"\n      aria-label=\"Drifting wall of tiles\"\n    >\n      <div ref={planeRef} className=\"drift-wall__plane\">\n        {columnItems.map((col, c) => {\n          const meta = columnMeta[c];\n          const copies = Array.from({ length: meta.copies });\n          return (\n            <div className=\"drift-wall__col\" key={`col-${c}`}>\n              <div className=\"drift-wall__track\" ref={el => (trackRefs.current[c] = el)}>\n                {copies.map((_, copyIndex) =>\n                  col.map((item, itemIndex) => renderTile(item, `${c}-${copyIndex}-${itemIndex}`, c))\n                )}\n              </div>\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n};\n\nexport default DriftWall;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": []
}