Dirck Mulder
Components||3 min read

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:

bash
npm install lenis

We also export a companion component for individual cards:

tsx
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.

tsx
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:

tsx
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:

tsx
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:

tsx
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

tsx
<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.style is intentional: React state updates would be too slow for per-frame animation.
  • The isUpdatingRef guard prevents overlapping animation frames from interfering with each other.
  • Setting will-change: transform and backface-visibility: hidden on cards before the animation starts eliminates paint flicker on most browsers.