360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
'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 }) => (
|
|
<div className={`scroll-stack-card ${itemClassName}`.trim()}>{children}</div>
|
|
);
|
|
|
|
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<HTMLDivElement>(null);
|
|
const stackCompletedRef = useRef(false);
|
|
const animationFrameRef = useRef<number | null>(null);
|
|
const lenisRef = useRef<Lenis | null>(null);
|
|
const cardsRef = useRef<HTMLElement[]>([]);
|
|
const lastTransformsRef = useRef<Map<number, CardTransform>>(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<number[]>([]);
|
|
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<HTMLElement>('.scroll-stack-end')
|
|
: scrollerRef.current?.querySelector<HTMLElement>('.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<HTMLElement>('.scroll-stack-card')
|
|
: scroller.querySelectorAll<HTMLElement>('.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 (
|
|
<div
|
|
className={`scroll-stack-scroller ${className}`.trim()}
|
|
ref={scrollerRef}
|
|
style={useWindowScroll ? { overflow: 'visible', height: 'auto', position: 'static' } : undefined}
|
|
>
|
|
<div className="scroll-stack-inner" style={disabled ? { padding: 0, minHeight: 0 } : undefined}>
|
|
{children}
|
|
{/* Spacer so the last pin can release cleanly */}
|
|
<div className="scroll-stack-end" />
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ScrollStack;
|