'use client'; import React, { useLayoutEffect, useRef, useCallback, ReactNode } from 'react'; import Lenis from 'lenis'; import './ScrollStack.css'; export const ScrollStackItem = ({ children, itemClassName = '' }: { children: ReactNode; itemClassName?: string }) => (
{children}
); interface CardTransform { translateY: number; scale: number; rotation: number; blur: number; } interface ScrollStackProps { children: ReactNode; className?: string; itemDistance?: number; itemScale?: number; itemStackDistance?: number; stackPosition?: string; scaleEndPosition?: string; baseScale?: number; scaleDuration?: number; rotationAmount?: number; blurAmount?: number; hideBehind?: boolean; useWindowScroll?: boolean; disabled?: boolean; onStackComplete?: () => void; } const ScrollStack = ({ children, className = '', itemDistance = 100, itemScale = 0.03, itemStackDistance = 30, stackPosition = '20%', scaleEndPosition = '10%', baseScale = 0.85, rotationAmount = 0, blurAmount = 0, hideBehind = false, useWindowScroll = false, disabled = false, onStackComplete, }: ScrollStackProps) => { 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); // Cached, transform-independent layout positions — re-reading getBoundingClientRect() // every frame would pick up each card's own translateY from the previous frame and // feed back into itself, causing jitter. These are measured once (and on resize). const cardTopsRef = useRef([]); const endTopRef = useRef(0); const calculateProgress = useCallback((scrollTop: number, start: number, end: number) => { if (scrollTop < start) return 0; if (scrollTop > end) return 1; return (scrollTop - start) / (end - start); }, []); const parsePercentage = useCallback((value: string | number, containerHeight: number) => { if (typeof value === 'string' && value.includes('%')) { return (parseFloat(value) / 100) * containerHeight; } return parseFloat(value as string); }, []); const getScrollData = useCallback(() => { if (useWindowScroll) { return { scrollTop: window.scrollY, containerHeight: window.innerHeight, }; } else { const scroller = scrollerRef.current!; return { scrollTop: scroller.scrollTop, containerHeight: scroller.clientHeight, }; } }, [useWindowScroll]); // Measures each card's static document position with any transform temporarily // cleared, so the cached value is never contaminated by our own animation. const measurePositions = useCallback(() => { const cards = cardsRef.current; const prevTransforms = cards.map(c => c.style.transform); cards.forEach(c => { c.style.transform = 'none'; }); const endElement = useWindowScroll ? document.querySelector('.scroll-stack-end') : scrollerRef.current?.querySelector('.scroll-stack-end'); const prevEndTransform = endElement?.style.transform; if (endElement) endElement.style.transform = 'none'; if (useWindowScroll) { cardTopsRef.current = cards.map(c => c.getBoundingClientRect().top + window.scrollY); endTopRef.current = endElement ? endElement.getBoundingClientRect().top + window.scrollY : 0; } else { cardTopsRef.current = cards.map(c => c.offsetTop); endTopRef.current = endElement ? endElement.offsetTop : 0; } cards.forEach((c, i) => { c.style.transform = prevTransforms[i]; }); if (endElement && prevEndTransform !== undefined) endElement.style.transform = prevEndTransform; }, [useWindowScroll]); const updateCardTransforms = useCallback(() => { if (!cardsRef.current.length || isUpdatingRef.current) return; isUpdatingRef.current = true; const { scrollTop, containerHeight } = getScrollData(); const stackPositionPx = parsePercentage(stackPosition, containerHeight); const scaleEndPositionPx = parsePercentage(scaleEndPosition, containerHeight); const endElementTop = endTopRef.current; // Which card is currently on top of the stack (used for blur + hide-behind) let topCardIndex = 0; for (let j = 0; j < cardsRef.current.length; j++) { const jCardTop = cardTopsRef.current[j] ?? 0; const jTriggerStart = jCardTop - stackPositionPx - itemStackDistance * j; if (scrollTop >= jTriggerStart) topCardIndex = j; } cardsRef.current.forEach((card, i) => { if (!card) return; const cardTop = cardTopsRef.current[i] ?? 0; 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 && i < topCardIndex) { const depthInStack = topCardIndex - i; blur = Math.max(0, depthInStack * blurAmount); } // Hide cards stacked behind the current top card entirely if (hideBehind) { card.style.opacity = i < topCardIndex ? '0' : '1'; } 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: CardTransform = { 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)` : ''; card.style.transform = transform; card.style.filter = filter; 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, hideBehind, useWindowScroll, onStackComplete, calculateProgress, parsePercentage, getScrollData, ]); const handleScroll = useCallback(() => { updateCardTransforms(); }, [updateCardTransforms]); const setupLenis = useCallback(() => { if (useWindowScroll) { const lenis = new Lenis({ duration: 1.2, easing: (t: number) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), smoothWheel: true, touchMultiplier: 2, wheelMultiplier: 1, syncTouch: true, syncTouchLerp: 0.075, }); lenis.on('scroll', handleScroll); const raf = (time: number) => { 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') as HTMLElement, duration: 1.2, easing: (t: number) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), smoothWheel: true, touchMultiplier: 2, gestureOrientation: 'vertical', wheelMultiplier: 1, syncTouch: true, syncTouchLerp: 0.075, }); lenis.on('scroll', handleScroll); const raf = (time: number) => { 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; // Disabled (e.g. on mobile): clear any pinning transforms so the cards // flow as a normal scrollable list, and skip the whole sticky-stack setup. if (disabled) { cards.forEach((card) => { card.style.transform = ''; card.style.filter = ''; card.style.opacity = ''; card.style.marginBottom = ''; card.style.willChange = ''; }); transformsCache.clear(); return; } cards.forEach((card, i) => { if (i < cards.length - 1) { card.style.marginBottom = `${itemDistance}px`; } card.style.willChange = 'transform, filter'; card.style.transformOrigin = 'top center'; card.style.backfaceVisibility = 'hidden'; card.style.transform = 'translateZ(0)'; }); measurePositions(); setupLenis(); updateCardTransforms(); const handleResize = () => { measurePositions(); updateCardTransforms(); }; window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } if (lenisRef.current) { lenisRef.current.destroy(); } stackCompletedRef.current = false; cardsRef.current = []; transformsCache.clear(); isUpdatingRef.current = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [itemDistance, itemScale, itemStackDistance, stackPosition, scaleEndPosition, baseScale, rotationAmount, blurAmount, useWindowScroll, disabled, onStackComplete, setupLenis, updateCardTransforms, measurePositions]); return (
{children} {/* Spacer so the last pin can release cleanly */}
); }; export default ScrollStack;