UNPKG

reactbits-mcp-server

Version:

MCP Server for React Bits - Access 99+ React components with animations, backgrounds, and UI elements

262 lines (226 loc) 8.19 kB
import { useLayoutEffect, useRef, useCallback } from "react"; import Lenis from "lenis"; export const ScrollStackItem = ({ children, itemClassName = "" }) => ( <div className={`scroll-stack-card relative w-full h-80 my-8 p-12 rounded-[40px] shadow-[0_0_30px_rgba(0,0,0,0.1)] box-border origin-top will-change-transform ${itemClassName}`.trim()} style={{ backfaceVisibility: 'hidden', transformStyle: 'preserve-3d', }} > {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, 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 updateCardTransforms = useCallback(() => { const scroller = scrollerRef.current; if (!scroller || !cardsRef.current.length || isUpdatingRef.current) return; isUpdatingRef.current = true; const scrollTop = scroller.scrollTop; const containerHeight = scroller.clientHeight; const stackPositionPx = parsePercentage(stackPosition, containerHeight); const scaleEndPositionPx = parsePercentage(scaleEndPosition, containerHeight); const endElement = scroller.querySelector('.scroll-stack-end'); const endElementTop = endElement ? endElement.offsetTop : 0; cardsRef.current.forEach((card, i) => { if (!card) return; const cardTop = card.offsetTop; 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 = cardsRef.current[j].offsetTop; 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)` : ''; 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, onStackComplete, calculateProgress, parsePercentage, ]); const handleScroll = useCallback(() => { updateCardTransforms(); }, [updateCardTransforms]); const setupLenis = useCallback(() => { const scroller = scrollerRef.current; if (!scroller) return; const lenis = new Lenis({ wrapper: scroller, content: scroller.querySelector('.scroll-stack-inner'), duration: 1.2, easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), smoothWheel: true, touchMultiplier: 2, infinite: false, wheelMultiplier: 1, lerp: 0.1, syncTouch: true, syncTouchLerp: 0.075, }); lenis.on('scroll', handleScroll); const raf = (time) => { lenis.raf(time); animationFrameRef.current = requestAnimationFrame(raf); }; animationFrameRef.current = requestAnimationFrame(raf); lenisRef.current = lenis; return lenis; }, [handleScroll]); useLayoutEffect(() => { const scroller = scrollerRef.current; if (!scroller) return; const cards = Array.from(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'; 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'; }); 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, onStackComplete, setupLenis, updateCardTransforms, ]); return ( <div className={`relative w-full h-full overflow-y-auto overflow-x-visible ${className}`.trim()} ref={scrollerRef} style={{ overscrollBehavior: 'contain', WebkitOverflowScrolling: 'touch', scrollBehavior: 'smooth', WebkitTransform: 'translateZ(0)', transform: 'translateZ(0)', willChange: 'scroll-position' }} > <div className="scroll-stack-inner pt-[20vh] px-20 pb-[50rem] min-h-screen"> {children} {/* Spacer so the last pin can release cleanly */} <div className="scroll-stack-end w-full h-px" /> </div> </div> ); }; export default ScrollStack;