Create a Scroll Stack Effect in React
Stack cards on top of each other as the user scrolls, creating a layered depth effect tied to scroll position.
Scrolling through a flat list of cards feels like flipping through a folder. Stacking them on scroll feels like building something. That difference matters more than you might expect.
The final result
What we are building
A scrollable container where cards pin to the top and stack on top of each other as you scroll through them. Each card scales down slightly when it gets pushed behind a new one, and you can optionally add rotation and blur to deepen the effect.
Setting up
This component uses Lenis for smooth scrolling:
npm install lenisWe also export a companion component for individual cards:
import Lenis from 'lenis';
import { useLayoutEffect, useRef, useCallback } from 'react';Building the component
The ScrollStackItem
Each card gets a class name scroll-stack-card which the parent uses to query DOM elements directly. The origin-top class is critical: all scale transforms shrink from the top edge so cards appear to pin in place.
export const ScrollStackItem: React.FC<ScrollStackItemProps> = ({
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>
);Calculating card position
The core of the effect is in updateCardTransforms. For each card, we calculate progress through a scroll range and derive a translateY and scale value:
const scaleProgress = calculateProgress(scrollTop, triggerStart, triggerEnd);
const targetScale = baseScale + i * itemScale;
const scale = 1 - scaleProgress * (1 - targetScale);
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;
}We skip DOM writes when values have not changed enough to matter, which keeps the animation loop cheap:
const hasChanged =
!lastTransform ||
Math.abs(lastTransform.translateY - newTransform.translateY) > 0.1 ||
Math.abs(lastTransform.scale - newTransform.scale) > 0.001;Lenis integration
Lenis gives you buttery scroll and fires a scroll event we hook into. For contained scroll (not window scroll), we pass the wrapper and inner elements:
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,
});
lenis.on('scroll', handleScroll);How to use it
<ScrollStack itemDistance={100} baseScale={0.85} blurAmount={0}>
<ScrollStackItem itemClassName='bg-blue-900'>
<h2>Card One</h2>
</ScrollStackItem>
<ScrollStackItem itemClassName='bg-purple-900'>
<h2>Card Two</h2>
</ScrollStackItem>
<ScrollStackItem itemClassName='bg-indigo-900'>
<h2>Card Three</h2>
</ScrollStackItem>
</ScrollStack>| Prop | Default | Description |
|------|---------|-------------|
| itemDistance | 100 | Scroll distance before next card starts stacking |
| itemScale | 0.03 | Scale reduction per stacked card |
| baseScale | 0.85 | Minimum scale for cards deep in the stack |
| rotationAmount | 0 | Slight tilt per card for a fanned look |
| blurAmount | 0 | Blur applied to cards beneath the top |
| useWindowScroll | false | Attach to window scroll instead of contained scroll |
Key takeaways
- Writing transforms directly to
element.styleis intentional: React state updates would be too slow for per-frame animation. - The
isUpdatingRefguard prevents overlapping animation frames from interfering with each other. - Setting
will-change: transformandbackface-visibility: hiddenon cards before the animation starts eliminates paint flicker on most browsers.