// Fanned card carousel (ported from the GSAP card-fan component to this // project's plain-React/Babel setup). Cards fan out, hover-spread, and paginate. const FAN_MAX_VISIBLE = 7; const FAN_HALF = 3; const FAN_POSITIONS = [ { rot: -21, scale: 0.7756, x: -30, y: 7.3, zIndex: 1 }, { rot: -14, scale: 0.8498, x: -22, y: 4.0, zIndex: 2 }, { rot: -7, scale: 0.9346, x: -11, y: 1.3, zIndex: 3 }, { rot: 0, scale: 1.0, x: 0, y: 0.0, zIndex: 10 }, { rot: 7, scale: 0.9346, x: 11, y: 1.3, zIndex: 3 }, { rot: 14, scale: 0.8498, x: 22, y: 4.0, zIndex: 2 }, { rot: 21, scale: 0.7756, x: 30, y: 7.3, zIndex: 1 }, ]; function fanResponsiveMultiplier(width) { if (width < 480) return 0.28; if (width < 640) return 0.38; if (width < 768) return 0.5; if (width < 1024) return 0.75; return 1.0; } function fanHeightMultiplier(width) { let idealPx; if (width < 480) idealPx = 22 * 16; else if (width < 640) idealPx = 26 * 16; else if (width < 768) idealPx = 28 * 16; else if (width < 1024) idealPx = 34 * 16; else idealPx = 38 * 16; const available = window.innerHeight * 0.7; if (available >= idealPx) return 1; return available / idealPx; } function fanSlotConfig(totalCards, slot) { if (totalCards >= FAN_MAX_VISIBLE) return FAN_POSITIONS[slot]; const center = totalCards >> 1; const distance = totalCards > 1 ? (slot - center) / center : 0; const absDistance = Math.abs(distance); return { rot: distance * 21, scale: 1.0 - 0.2244 * absDistance * absDistance, x: distance * 30, y: absDistance * absDistance * 7.3, zIndex: 10 - Math.abs(slot - center), }; } function FanCarousel({ cards }) { const containerRef = React.useRef(null); const isAnimating = React.useRef(false); const hasEntered = React.useRef(false); const directionRef = React.useRef(null); const prevVisible = React.useRef(new Set()); const totalCards = cards.length; const needsPagination = totalCards > FAN_MAX_VISIBLE; const [centerIndex, setCenterIndex] = React.useState(needsPagination ? FAN_HALF : totalCards >> 1); const getVisibleMap = React.useCallback((center) => { const map = new Map(); if (!needsPagination) { cards.forEach((_, i) => map.set(i, i)); return map; } for (let slot = 0; slot < FAN_MAX_VISIBLE; slot++) { map.set(((center + slot - FAN_HALF) % totalCards + totalCards) % totalCards, slot); } return map; }, [totalCards, needsPagination, cards]); const cycle = React.useCallback((direction) => { if (isAnimating.current || !needsPagination) return; isAnimating.current = true; directionRef.current = direction; setCenterIndex((prev) => direction === 'right' ? (prev + 1) % totalCards : (prev - 1 + totalCards) % totalCards ); }, [totalCards, needsPagination]); React.useEffect(() => { const container = containerRef.current; if (!container || !totalCards || !window.gsap) return; const gsap = window.gsap; const cardElements = Array.from(container.querySelectorAll('.fan-card')); if (!cardElements.length) return; const visibleMap = getVisibleMap(centerIndex); const previouslyVisible = prevVisible.current; const direction = directionRef.current; const isFirstMount = !hasEntered.current; const multiplier = fanResponsiveMultiplier(window.innerWidth); const hMult = fanHeightMultiplier(window.innerWidth); const slotCount = needsPagination ? FAN_MAX_VISIBLE : totalCards; const config = (slot) => fanSlotConfig(slotCount, slot); if (isFirstMount) isAnimating.current = true; let completedCount = 0; const visibleCount = visibleMap.size; const onCardDone = () => { if (++completedCount >= visibleCount) { isAnimating.current = false; if (isFirstMount) hasEntered.current = true; } }; cardElements.forEach((card, cardIndex) => { const slot = visibleMap.get(cardIndex); const wasVisible = previouslyVisible.has(cardIndex); if (slot !== undefined) { const { x, y, rot, scale, zIndex } = config(slot); const target = { x: `${x * multiplier}rem`, y: `${y * hMult}rem`, rotation: rot, scale, opacity: 1, zIndex, }; if (isFirstMount) { gsap.set(card, { x: 0, y: `${12 * hMult}rem`, rotation: 0, scale: 0.5, opacity: 0 }); gsap.to(card, { ...target, duration: 1.2, ease: 'elastic.out(1.05,.78)', delay: 0.2 + slot * 0.06, onComplete: onCardDone }); } else if (!wasVisible) { const enterX = direction === 'right' ? 40 : -40; gsap.set(card, { x: `${enterX}rem`, y: `${y * hMult}rem`, rotation: direction === 'right' ? 30 : -30, scale: 0.5, opacity: 0 }); gsap.to(card, { ...target, duration: 0.6, ease: 'power2.out', onComplete: onCardDone }); } else { gsap.to(card, { ...target, duration: 0.5, ease: 'power2.out', onComplete: onCardDone }); } } else if (wasVisible) { const exitX = direction === 'right' ? -40 : 40; gsap.to(card, { x: `${exitX}rem`, opacity: 0, scale: 0.5, rotation: direction === 'right' ? -30 : 30, duration: 0.4, ease: 'power2.in', zIndex: 0 }); } else if (isFirstMount) { gsap.set(card, { opacity: 0, scale: 0.3, x: 0, y: 0, zIndex: 0 }); } }); prevVisible.current = new Set(visibleMap.keys()); // Hover interactions const visibleEntries = []; cardElements.forEach((el, i) => { const slot = visibleMap.get(i); if (slot !== undefined) visibleEntries.push({ el, slot }); }); visibleEntries.sort((a, b) => a.slot - b.slot); let activeSlot = null; let leaveTimer = null; const centerSlot = visibleEntries.length >> 1; const updateHoverLayout = (hoveredSlot) => { const mult = fanResponsiveMultiplier(window.innerWidth); const hM = fanHeightMultiplier(window.innerWidth); visibleEntries.forEach(({ el, slot }) => { const base = config(slot); let targetX = base.x * mult; let targetY = base.y * hM; let targetRot = base.rot; let targetScale = base.scale; let delay = 0; if (hoveredSlot !== null) { const distance = Math.abs(slot - hoveredSlot); delay = distance * 0.02; if (slot === hoveredSlot) { targetY -= 2.5 * hM; targetScale *= 1.08; } else { const normalized = centerSlot > 0 ? (slot - centerSlot) / centerSlot : 0; const pushStrength = 8 * (1 - Math.abs(normalized)) * (1 + 0.2 * Math.max(0, 3 - distance)); if (slot < hoveredSlot) { targetX -= pushStrength * mult; targetRot -= 3 / (distance + 1); } else { targetX += pushStrength * mult; targetRot += 3 / (distance + 1); } if (slot === visibleEntries.length - 1 && hoveredSlot < centerSlot) targetY -= 1 * hM; if (slot === 0 && hoveredSlot > centerSlot) targetY -= 1 * hM; } } else { delay = Math.abs(slot - centerSlot) * 0.02; } gsap.to(el, { x: `${targetX}rem`, y: `${targetY}rem`, rotation: targetRot, scale: targetScale, duration: 0.5, delay, ease: 'elastic.out(1,.75)', overwrite: 'auto', }); gsap.set(el, { zIndex: base.zIndex }); }); }; const enterHandlers = visibleEntries.map(({ el, slot }) => { const handler = () => { if (isAnimating.current) return; if (leaveTimer) { clearTimeout(leaveTimer); leaveTimer = null; } if (activeSlot !== slot) { activeSlot = slot; updateHoverLayout(slot); } }; el.addEventListener('mouseenter', handler); return { el, handler }; }); const onMouseLeave = () => { if (isAnimating.current) return; if (leaveTimer) clearTimeout(leaveTimer); leaveTimer = setTimeout(() => { activeSlot = null; updateHoverLayout(null); }, 50); }; container.addEventListener('mouseleave', onMouseLeave); const onResize = () => { if (!isAnimating.current) updateHoverLayout(activeSlot); }; window.addEventListener('resize', onResize); return () => { enterHandlers.forEach(({ el, handler }) => el.removeEventListener('mouseenter', handler)); container.removeEventListener('mouseleave', onMouseLeave); window.removeEventListener('resize', onResize); if (leaveTimer) clearTimeout(leaveTimer); }; }, [centerIndex, totalCards, getVisibleMap, needsPagination]); if (!totalCards) return null; const chevron = (direction) => ( ); return (
{cards.map((card, index) => (
{card.alt
))}
{needsPagination && (
{cards.map((_, i) => ( ))}
)}
); } window.FanCarousel = FanCarousel;