import { useLayoutEffect, useRef, useCallback } from 'react'; import Lenis from 'lenis'; import './ScrollStack.css'; export const ScrollStackItem = ({ children, itemClassName = '' }) => (
{children}
); const ScrollStack = ({ children, className = '', itemDistance = 100, itemScale = 0.03, itemStackDistance = 30, stackPosition = '20%', scaleEndPosition = '10%', baseScale = 0.85, scaleDuration = 0.5, rotationAmount = 0, blurAmount = 0, useWindowScroll = false, onStackComplete }) => { const scrollerRef = useRef(null); const stackCompletedRef = useRef(false); const animationFrameRef = useRef(null); const lenisRef = useRef(null); const cardsRef = useRef([]); const lastTransformsRef = useRef(new Map()); const isUpdatingRef = useRef(false); const calculateProgress = useCallback((scrollTop, start, end) => { if (scrollTop < start) return 0; if (scrollTop > end) return 1; return (scrollTop - start) / (end - start); }, []); const parsePercentage = useCallback((value, containerHeight) => { if (typeof value === 'string' && value.includes('%')) { return (parseFloat(value) / 100) * containerHeight; } return parseFloat(value); }, []); const getScrollData = useCallback(() => { if (useWindowScroll) { return { scrollTop: window.scrollY, containerHeight: window.innerHeight, scrollContainer: document.documentElement }; } else { const scroller = scrollerRef.current; return { scrollTop: scroller.scrollTop, containerHeight: scroller.clientHeight, scrollContainer: scroller }; } }, [useWindowScroll]); const getElementOffset = useCallback( element => { if (useWindowScroll) { const rect = element.getBoundingClientRect(); return rect.top + window.scrollY; } else { return element.offsetTop; } }, [useWindowScroll] ); const updateCardTransforms = useCallback(() => { if (!cardsRef.current.length || isUpdatingRef.current) return; isUpdatingRef.current = true; const { scrollTop, containerHeight, scrollContainer } = getScrollData(); const stackPositionPx = parsePercentage(stackPosition, containerHeight); const scaleEndPositionPx = parsePercentage(scaleEndPosition, containerHeight); const endElement = useWindowScroll ? document.querySelector('.scroll-stack-end') : scrollerRef.current?.querySelector('.scroll-stack-end'); const endElementTop = endElement ? getElementOffset(endElement) : 0; cardsRef.current.forEach((card, i) => { if (!card) return; const cardTop = getElementOffset(card); const triggerStart = cardTop - stackPositionPx - itemStackDistance * i; const triggerEnd = cardTop - scaleEndPositionPx; const pinStart = cardTop - stackPositionPx - itemStackDistance * i; const pinEnd = endElementTop - containerHeight / 2; const scaleProgress = calculateProgress(scrollTop, triggerStart, triggerEnd); const targetScale = baseScale + i * itemScale; const scale = 1 - scaleProgress * (1 - targetScale); const rotation = rotationAmount ? i * rotationAmount * scaleProgress : 0; let blur = 0; if (blurAmount) { let topCardIndex = 0; for (let j = 0; j < cardsRef.current.length; j++) { const jCardTop = getElementOffset(cardsRef.current[j]); const jTriggerStart = jCardTop - stackPositionPx - itemStackDistance * j; if (scrollTop >= jTriggerStart) { topCardIndex = j; } } if (i < topCardIndex) { const depthInStack = topCardIndex - i; blur = Math.max(0, depthInStack * blurAmount); } } let translateY = 0; const isPinned = scrollTop >= pinStart && scrollTop <= pinEnd; if (isPinned) { translateY = scrollTop - cardTop + stackPositionPx + itemStackDistance * i; } else if (scrollTop > pinEnd) { translateY = pinEnd - cardTop + stackPositionPx + itemStackDistance * i; } const newTransform = { translateY: Math.round(translateY * 100) / 100, scale: Math.round(scale * 1000) / 1000, rotation: Math.round(rotation * 100) / 100, blur: Math.round(blur * 100) / 100 }; const lastTransform = lastTransformsRef.current.get(i); const hasChanged = !lastTransform || Math.abs(lastTransform.translateY - newTransform.translateY) > 0.1 || Math.abs(lastTransform.scale - newTransform.scale) > 0.001 || Math.abs(lastTransform.rotation - newTransform.rotation) > 0.1 || Math.abs(lastTransform.blur - newTransform.blur) > 0.1; if (hasChanged) { const transform = `translate3d(0, ${newTransform.translateY}px, 0) scale(${newTransform.scale}) rotate(${newTransform.rotation}deg)`; const filter = newTransform.blur > 0 ? `blur(${newTransform.blur}px)` : ''; // Apply transforms directly for better performance card.style.transform = transform; card.style.filter = filter; card.style.willChange = 'transform'; lastTransformsRef.current.set(i, newTransform); } if (i === cardsRef.current.length - 1) { const isInView = scrollTop >= pinStart && scrollTop <= pinEnd; if (isInView && !stackCompletedRef.current) { stackCompletedRef.current = true; onStackComplete?.(); } else if (!isInView && stackCompletedRef.current) { stackCompletedRef.current = false; } } }); isUpdatingRef.current = false; }, [ itemScale, itemStackDistance, stackPosition, scaleEndPosition, baseScale, rotationAmount, blurAmount, useWindowScroll, onStackComplete, calculateProgress, parsePercentage, getScrollData, getElementOffset ]); const handleScroll = useCallback(() => { updateCardTransforms(); }, [updateCardTransforms]); const setupLenis = useCallback(() => { if (useWindowScroll) { const lenis = new Lenis({ duration: 0.8, easing: t => Math.min(1, 1.001 - Math.pow(2, -10 * t)), smoothWheel: true, touchMultiplier: 1.5, infinite: false, wheelMultiplier: 0.8, lerp: 0.05, syncTouch: true, syncTouchLerp: 0.05 }); lenis.on('scroll', handleScroll); const raf = time => { lenis.raf(time); animationFrameRef.current = requestAnimationFrame(raf); }; animationFrameRef.current = requestAnimationFrame(raf); lenisRef.current = lenis; return lenis; } else { const scroller = scrollerRef.current; if (!scroller) return; const lenis = new Lenis({ wrapper: scroller, content: scroller.querySelector('.scroll-stack-inner'), duration: 0.8, easing: t => Math.min(1, 1.001 - Math.pow(2, -10 * t)), smoothWheel: true, touchMultiplier: 1.5, infinite: false, gestureOrientationHandler: true, normalizeWheel: true, wheelMultiplier: 0.8, touchInertiaMultiplier: 25, lerp: 0.05, syncTouch: true, syncTouchLerp: 0.05, touchInertia: 0.4 }); lenis.on('scroll', handleScroll); const raf = time => { lenis.raf(time); animationFrameRef.current = requestAnimationFrame(raf); }; animationFrameRef.current = requestAnimationFrame(raf); lenisRef.current = lenis; return lenis; } }, [handleScroll, useWindowScroll]); useLayoutEffect(() => { const scroller = scrollerRef.current; if (!scroller) return; const cards = Array.from( useWindowScroll ? document.querySelectorAll('.scroll-stack-card') : scroller.querySelectorAll('.scroll-stack-card') ); cardsRef.current = cards; const transformsCache = lastTransformsRef.current; cards.forEach((card, i) => { if (i < cards.length - 1) { card.style.marginBottom = `${itemDistance}px`; } card.style.willChange = 'transform, filter, opacity'; card.style.transformOrigin = 'top center'; card.style.backfaceVisibility = 'hidden'; card.style.transform = 'translateZ(0)'; card.style.webkitTransform = 'translateZ(0)'; card.style.perspective = '1000px'; card.style.webkitPerspective = '1000px'; card.style.contain = 'layout style paint'; card.style.isolation = 'isolate'; card.style.webkitFontSmoothing = 'antialiased'; card.style.mozOsxFontSmoothing = 'grayscale'; }); setupLenis(); updateCardTransforms(); return () => { if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } if (lenisRef.current) { lenisRef.current.destroy(); } stackCompletedRef.current = false; cardsRef.current = []; transformsCache.clear(); isUpdatingRef.current = false; }; }, [ itemDistance, itemScale, itemStackDistance, stackPosition, scaleEndPosition, baseScale, scaleDuration, rotationAmount, blurAmount, useWindowScroll, onStackComplete, setupLenis, updateCardTransforms ]); return (
{children} {/* Spacer so the last pin can release cleanly */}
); }; export default ScrollStack;