coders-club / src /components /ScrollStack.jsx
kumar-aditya's picture
Upload 108 files
a7b8df9 verified
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;