{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "AccordionGallery-JS-CSS", "title": "AccordionGallery", "description": "Panels expand on hover or focus, revealing parallax imagery and captions.", "type": "registry:component", "files": [ { "type": "registry:component", "path": "AccordionGallery/AccordionGallery.css", "content": ".accordion-gallery {\n --ag-accent: #ffffff;\n --ag-overlay: #060010;\n --ag-text: #ffffff;\n --ag-gap: 10px;\n --ag-radius: 16px;\n --ag-media-size: 320px;\n\n display: flex;\n flex-direction: row;\n gap: var(--ag-gap);\n width: 100%;\n max-width: 100%;\n perspective: 1400px;\n perspective-origin: 50% 50%;\n}\n\n.accordion-gallery--vertical {\n flex-direction: column;\n}\n\n.ag-panel {\n position: relative;\n flex: 1 1 0;\n min-width: 0;\n min-height: 0;\n overflow: hidden;\n border-radius: var(--ag-radius);\n cursor: pointer;\n display: block;\n text-decoration: none;\n outline: none;\n transform-style: preserve-3d;\n transform-origin: center center;\n background: #0a0713;\n box-shadow: 0 10px 30px -18px rgba(0, 0, 0, 0.8);\n will-change: flex-grow, transform;\n -webkit-tap-highlight-color: transparent;\n}\n\n.ag-panel:focus-visible {\n box-shadow:\n 0 0 0 2px var(--ag-accent),\n 0 10px 30px -18px rgba(0, 0, 0, 0.8);\n}\n\n.ag-panel__frame {\n position: absolute;\n inset: 0;\n overflow: hidden;\n border-radius: inherit;\n}\n\n.ag-panel__media {\n --ag-gray: 1;\n --ag-dim: 0.35;\n position: absolute;\n top: 50%;\n left: 50%;\n width: var(--ag-media-size);\n height: 100%;\n filter: grayscale(var(--ag-gray));\n will-change: transform, filter;\n}\n\n.accordion-gallery--vertical .ag-panel__media {\n width: 100%;\n height: var(--ag-media-size);\n}\n\n.ag-panel__media img {\n width: 100%;\n height: 100%;\n object-fit: cover;\n display: block;\n user-select: none;\n -webkit-user-drag: none;\n}\n\n.ag-panel__overlay {\n position: absolute;\n inset: 0;\n pointer-events: none;\n background:\n linear-gradient(180deg, transparent 45%, color-mix(in srgb, var(--ag-overlay) 78%, transparent) 100%),\n color-mix(in srgb, var(--ag-overlay) calc(var(--ag-dim, 0.35) * 100%), transparent);\n}\n\n.ag-panel__label {\n position: absolute;\n left: 20px;\n bottom: 20px;\n right: 20px;\n display: flex;\n align-items: center;\n gap: 12px;\n pointer-events: none;\n z-index: 2;\n}\n\n.ag-panel__bar {\n flex: 0 0 auto;\n width: 3px;\n height: 26px;\n border-radius: 3px;\n background: var(--ag-accent);\n opacity: 0;\n box-shadow: 0 0 12px color-mix(in srgb, var(--ag-accent) 60%, transparent);\n}\n\n.ag-panel__text {\n color: var(--ag-text);\n font-family: inherit;\n font-weight: 600;\n font-size: clamp(1rem, 1.4vw, 1.4rem);\n letter-spacing: 0.01em;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n opacity: 0;\n text-shadow: 0 2px 14px rgba(0, 0, 0, 0.55);\n}\n\n@media (max-width: 520px) {\n .accordion-gallery {\n flex-direction: column;\n perspective: none;\n height: auto !important;\n }\n .ag-panel {\n min-height: 84px;\n transform: none !important;\n }\n .accordion-gallery .ag-panel__media {\n width: 100%;\n height: var(--ag-media-size);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .ag-panel,\n .ag-panel__media {\n will-change: auto;\n }\n}\n" }, { "type": "registry:component", "path": "AccordionGallery/AccordionGallery.jsx", "content": "import { useRef, useEffect, useState, useCallback } from 'react';\nimport { gsap } from 'gsap';\n\nimport './AccordionGallery.css';\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 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