{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "AccordionGallery-JS-TW", "title": "AccordionGallery", "description": "Panels expand on hover or focus, revealing parallax imagery and captions.", "type": "registry:component", "files": [ { "type": "registry:component", "path": "AccordionGallery/AccordionGallery.jsx", "content": "import { useRef, useEffect, useState, useCallback } from 'react';\nimport { gsap } from 'gsap';\n\nconst DEFAULT_ITEMS = [\n { image: 'https://picsum.photos/id/1015/900/1200', label: 'Canyon', link: '#' },\n { image: 'https://picsum.photos/id/1018/900/1200', label: 'Ridgeline', link: '#' },\n { image: 'https://picsum.photos/id/1039/900/1200', label: 'Falls', link: '#' },\n { image: 'https://picsum.photos/id/1043/900/1200', label: 'Harbour', link: '#' },\n { image: 'https://picsum.photos/id/1044/900/1200', label: 'Skyline', link: '#' }\n];\n\nconst AccordionGallery = ({\n items = DEFAULT_ITEMS,\n defaultIndex = 2,\n accentColor = '#ffffff',\n overlayColor = '#060010',\n textColor = '#ffffff',\n height = 460,\n gap = 10,\n radius = 16,\n expandRatio = 0.52,\n orientation = 'horizontal',\n duration = 0.6,\n ease = 'power3.out',\n parallax = 0.5,\n tilt = 8,\n stagger = 0.06,\n trigger = 'hover',\n showLabels = true,\n grayscale = true,\n className = ''\n}) => {\n const rootRef = useRef(null);\n const panelRefs = useRef([]);\n const mediaRefs = useRef([]);\n const barRefs = useRef([]);\n const textRefs = useRef([]);\n const tlRef = useRef(null);\n const firstRunRef = useRef(true);\n const mediaSizeRef = useRef(320);\n\n const vertical = orientation === 'vertical';\n const count = items.length;\n const [active, setActive] = useState(Math.min(Math.max(defaultIndex, 0), count - 1));\n\n const prefersReduced =\n typeof window !== 'undefined' && window.matchMedia\n ? window.matchMedia('(prefers-reduced-motion: reduce)').matches\n : false;\n\n const overlayBg = `linear-gradient(180deg, transparent 45%, color-mix(in srgb, ${overlayColor} 78%, transparent) 100%), color-mix(in srgb, ${overlayColor} calc(var(--ag-dim, 0.35) * 100%), transparent)`;\n\n const applyLayout = useCallback(\n animate => {\n const panels = panelRefs.current;\n if (!panels.length) return;\n\n const r = Math.min(Math.max(expandRatio, 0.2), 0.9);\n const grow = count > 1 ? (r * (count - 1)) / (1 - r) : 1;\n const mediaSize = mediaSizeRef.current;\n\n tlRef.current?.kill();\n const dur = animate && !prefersReduced ? duration : 0;\n const tl = gsap.timeline();\n\n panels.forEach((panel, i) => {\n if (!panel) return;\n const isActive = i === active;\n const media = mediaRefs.current[i];\n const bar = barRefs.current[i];\n const text = textRefs.current[i];\n\n const rot = isActive ? 0 : i < active ? tilt : -tilt;\n const rotProp = vertical ? { rotateX: -rot } : { rotateY: rot };\n\n tl.to(panel, { flexGrow: isActive ? grow : 1, ...rotProp, duration: dur, ease }, 0);\n\n if (media) {\n const drift = Math.max(-1.5, Math.min(1.5, active - i));\n const shift = drift * parallax * mediaSize * 0.06;\n const gray = grayscale ? (isActive ? 0 : 1) : 0;\n tl.to(\n media,\n {\n xPercent: -50,\n yPercent: -50,\n x: vertical ? 0 : isActive ? 0 : shift,\n y: vertical ? (isActive ? 0 : shift) : 0,\n '--ag-gray': gray,\n '--ag-dim': isActive ? 0 : 0.35,\n duration: dur,\n ease\n },\n 0\n );\n }\n\n if (showLabels && bar && text) {\n if (isActive) {\n tl.to([bar, text], { opacity: 1, x: 0, duration: dur, ease, stagger: prefersReduced ? 0 : stagger }, 0);\n } else {\n tl.to([bar, text], { opacity: 0, x: -14, duration: dur * 0.6, ease }, 0);\n }\n }\n });\n\n tlRef.current = tl;\n },\n [\n active,\n count,\n expandRatio,\n duration,\n ease,\n vertical,\n tilt,\n parallax,\n grayscale,\n showLabels,\n stagger,\n prefersReduced\n ]\n );\n\n useEffect(() => {\n const el = rootRef.current;\n if (!el) return;\n\n const measure = () => {\n const rect = el.getBoundingClientRect();\n const total = vertical ? rect.height : rect.width;\n const usable = Math.max(total - gap * (count - 1), 120);\n const size = Math.max(140, usable * Math.min(Math.max(expandRatio, 0.2), 0.9) * 1.22);\n mediaSizeRef.current = size;\n el.style.setProperty('--ag-media-size', `${size}px`);\n applyLayout(!firstRunRef.current);\n };\n\n measure();\n const ro = new ResizeObserver(measure);\n ro.observe(el);\n return () => ro.disconnect();\n }, [applyLayout, gap, count, expandRatio, vertical]);\n\n useEffect(() => {\n applyLayout(!firstRunRef.current);\n firstRunRef.current = false;\n }, [applyLayout]);\n\n useEffect(\n () => () => {\n tlRef.current?.kill();\n },\n []\n );\n\n const handleEnter = i => {\n if (trigger === 'hover') setActive(i);\n };\n\n const handleClick = (i, e) => {\n if (i !== active) {\n e.preventDefault();\n setActive(i);\n }\n };\n\n const handleKeyDown = (i, e) => {\n if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {\n e.preventDefault();\n setActive((i + 1) % count);\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {\n e.preventDefault();\n setActive((i - 1 + count) % count);\n }\n };\n\n return (\n