{ "$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 \n {items.map((item, i) => {\n const isActive = i === active;\n const Tag = item.link ? 'a' : 'div';\n return (\n (panelRefs.current[i] = el)}\n className=\"group relative block min-w-0 min-h-0 flex-[1_1_0] cursor-pointer overflow-hidden bg-[#0a0713] no-underline outline-none [transform-style:preserve-3d] [transform-origin:center] [box-shadow:0_10px_30px_-18px_rgba(0,0,0,0.8)] focus-visible:[box-shadow:0_0_0_2px_var(--ag-accent),0_10px_30px_-18px_rgba(0,0,0,0.8)] max-[520px]:min-h-[84px] max-[520px]:!transform-none\"\n style={{ borderRadius: `${radius}px`, '--ag-accent': accentColor, willChange: 'flex-grow, transform' }}\n href={item.link || undefined}\n onClick={e => handleClick(i, e)}\n onMouseEnter={() => handleEnter(i)}\n onFocus={() => setActive(i)}\n onKeyDown={e => handleKeyDown(i, e)}\n role=\"listitem\"\n tabIndex={0}\n aria-current={isActive ? 'true' : undefined}\n aria-label={item.label}\n >\n \n (mediaRefs.current[i] = el)}\n className=\"absolute top-1/2 left-1/2 [filter:grayscale(var(--ag-gray,1))]\"\n style={{\n width: vertical ? '100%' : 'var(--ag-media-size, 320px)',\n height: vertical ? 'var(--ag-media-size, 320px)' : '100%',\n willChange: 'transform, filter'\n }}\n >\n \n \n \n \n {showLabels && (\n \n (barRefs.current[i] = el)}\n className=\"h-[26px] w-[3px] flex-none rounded-[3px] opacity-0\"\n style={{\n background: accentColor,\n boxShadow: `0 0 12px color-mix(in srgb, ${accentColor} 60%, transparent)`\n }}\n />\n (textRefs.current[i] = el)}\n className=\"overflow-hidden text-ellipsis whitespace-nowrap text-[clamp(1rem,1.4vw,1.4rem)] font-semibold tracking-[0.01em] opacity-0 [text-shadow:0_2px_14px_rgba(0,0,0,0.55)]\"\n style={{ color: textColor }}\n >\n {item.label}\n \n \n )}\n \n );\n })}\n \n );\n};\n\nexport default AccordionGallery;\n" } ], "registryDependencies": [], "dependencies": [ "gsap@^3.13.0" ] }