Spaces:
Sleeping
Sleeping
| import { useLayoutEffect, useRef, useCallback } from 'react'; | |
| import Lenis from 'lenis'; | |
| import './ScrollStack.css'; | |
| export const ScrollStackItem = ({ children, itemClassName = '' }) => ( | |
| <div className={`scroll-stack-card ${itemClassName}`.trim()}>{children}</div> | |
| ); | |
| 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 ( | |
| <div className={`scroll-stack-scroller ${className}`.trim()} ref={scrollerRef}> | |
| <div className="scroll-stack-inner"> | |
| {children} | |
| {/* Spacer so the last pin can release cleanly */} | |
| <div className="scroll-stack-end" /> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default ScrollStack; | |