Scroll Stack

Create a smooth scrolling card stack effect with Lenis smooth scrolling and transform-based animations

Preview
Source Code
🚀

Performance

GPU-accelerated animations with Lenis smooth scrolling for buttery-smooth 60fps performance across all devices.

60fpsGPUSmooth

Customizable

Extensive configuration options including scale, rotation, blur effects, and stacking behavior to match your design needs.

FlexibleConfigEffects
📱

Responsive

Optimized for all screen sizes with touch-friendly interactions and adaptive layouts that work seamlessly on mobile and desktop.

MobileTouchAdaptive
🎨

Beautiful

Create stunning scroll-based interactions with smooth stacking animations, depth effects, and modern visual design patterns.

ModernDepthStunning

Overview

The Scroll Stack component creates a beautiful card stacking effect that responds to scroll with smooth animations. This component demonstrates advanced scroll-based interactions using Lenis for smooth scrolling and transform-based animations for optimal performance.

Key Features:

  • Smooth scrolling with Lenis integration for buttery-smooth scroll experience
  • Card stacking animations with scale, rotation, and blur effects
  • Transform-based animations for GPU-accelerated performance
  • Customizable stacking behavior with configurable distances and effects
  • Responsive design that works across all devices
  • Memory efficient with proper cleanup and optimization

What You'll Learn

This tutorial covers Lenis smooth scrolling integration, transform-based animations, scroll-triggered effects, and creating engaging card stack interactions that respond beautifully to user scroll.

Installation

First, let's set up a new Next.js project and install the required dependencies:

npx create-next-app@latest scroll-stack-app
cd scroll-stack-app

Install the required dependencies:

npm install lenis
npm install -D tailwindcss postcss autoprefixer

Dependencies Required

Make sure you have Node.js 18+ installed and Lenis for the smooth scrolling to work properly.

Clean up the default files and prepare for our custom implementation:

# Clear default content from these files
echo "" > src/app/page.js
echo "" > src/app/globals.css

Step 1: Component Structure & Lenis Setup

Let's start by creating the foundational structure with proper Lenis smooth scrolling integration:

ScrollStack Component
'use client'

import Lenis from 'lenis'
import type React from 'react'
import { type ReactNode, useCallback, useLayoutEffect, useRef } from 'react'
import './scroll-stack.css'

export interface ScrollStackItemProps {
  itemClassName?: string
  children: ReactNode
}

export const ScrollStackItem: React.FC<ScrollStackItemProps> = ({
  children,
  itemClassName = '',
}) => (
  <div className={`scroll-stack-card ${itemClassName}`.trim()}>{children}</div>
)

interface ScrollStackProps {
  className?: string
  children: ReactNode
  itemDistance?: number
  itemScale?: number
  itemStackDistance?: number
  stackPosition?: string
  scaleEndPosition?: string
  baseScale?: number
  scaleDuration?: number
  rotationAmount?: number
  blurAmount?: number
  useWindowScroll?: boolean
  onStackComplete?: () => void
}

const ScrollStack: React.FC<ScrollStackProps> = ({
  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<HTMLDivElement>(null)
  const lenisRef = useRef<Lenis | null>(null)
  const cardsRef = useRef<HTMLElement[]>([])
  
  // Component implementation will go here

  return (
    <div
      className={`scroll-stack-scroller ${className}`.trim()}
      ref={scrollerRef}
    >
      <div className="scroll-stack-inner">
        {children}
        <div className="scroll-stack-end" />
      </div>
    </div>
  )
}

export default ScrollStack
Base Styles
.scroll-stack-scroller {
  position: relative;
  width: 100%;
  height: 100%;
  overflow-y: auto;
  overflow-x: visible;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  scroll-behavior: smooth;
  -webkit-transform: translateZ(0);
  transform: translateZ(0);
  will-change: scroll-position;
}

.scroll-stack-inner {
  padding: 20vh 5rem 50rem;
  min-height: 100vh;
}

.scroll-stack-card {
  transform-origin: top center;
  will-change: transform, filter;
  backface-visibility: hidden;
  transform-style: preserve-3d;
  box-shadow: 0 0 30px rgba(0, 0, 0, 0.1);
  height: 20rem;
  width: 100%;
  margin: 30px 0;
  padding: 3rem;
  border-radius: 40px;
  box-sizing: border-box;
  -webkit-transform: translateZ(0);
  transform: translateZ(0);
  position: relative;
}

Key implementation details:

  • Client-side rendering: Using 'use client' directive for Next.js App Router
  • Lenis integration: Smooth scrolling with proper configuration
  • Memory management: Cleanup function to prevent memory leaks
  • Transform-based animations: GPU-accelerated performance
  • Performance optimization: Proper refs and callback optimization

Best Practice

Always use transform-based animations for optimal performance and ensure proper cleanup of Lenis instances to prevent memory leaks.

Step 2: Lenis Setup & Scroll Handling

Now we'll implement the Lenis smooth scrolling setup and scroll handling logic:

const ScrollStack: React.FC<ScrollStackProps> = ({
  // ... props
}) => {
  const scrollerRef = useRef<HTMLDivElement>(null)
  const lenisRef = useRef<Lenis | null>(null)
  const cardsRef = useRef<HTMLElement[]>([])
  const lastTransformsRef = useRef(new Map<number, any>())
  const isUpdatingRef = useRef(false)

  const setupLenis = useCallback(() => {
    if (useWindowScroll) {
      const lenis = new Lenis({
        duration: 1.2,
        easing: (t) => Math.min(1, 1.001 - 2 ** (-10 * t)),
        smoothWheel: true,
        touchMultiplier: 2,
        infinite: false,
        wheelMultiplier: 1,
        lerp: 0.1,
        syncTouch: true,
        syncTouchLerp: 0.075,
      })

      lenis.on('scroll', handleScroll)
      lenisRef.current = lenis
      return lenis
    }
    
    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) => Math.min(1, 1.001 - 2 ** (-10 * t)),
      smoothWheel: true,
      touchMultiplier: 2,
      infinite: false,
      gestureOrientation: 'vertical',
      wheelMultiplier: 1,
      lerp: 0.1,
      syncTouch: true,
      syncTouchLerp: 0.075,
    })

    lenis.on('scroll', handleScroll)
    lenisRef.current = lenis
    return lenis
  }, [useWindowScroll])

  const handleScroll = useCallback(() => {
    updateCardTransforms()
  }, [updateCardTransforms])

  // ... rest of implementation
}

Lenis features:

  • Smooth scrolling: Buttery-smooth scroll experience across devices
  • Touch optimization: Enhanced touch interactions for mobile
  • Performance: Optimized scroll handling with requestAnimationFrame
  • Customizable easing: Configurable animation curves
  • Memory efficient: Proper cleanup and event management

Lenis Insight

Lenis provides smooth scrolling that works seamlessly across all devices, with built-in touch support and performance optimizations for the best user experience.

Step 3: Card Transform Logic

Now we'll implement the core card stacking animation logic that creates the beautiful stacking effect:

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 endElement = useWindowScroll
    ? (document.querySelector('.scroll-stack-end') as HTMLElement)
    : (scrollerRef.current?.querySelector('.scroll-stack-end') as HTMLElement)

  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)` : ''

      card.style.transform = transform
      card.style.filter = filter

      lastTransformsRef.current.set(i, newTransform)
    }
  })

  isUpdatingRef.current = false
}, [
  itemScale,
  itemStackDistance,
  stackPosition,
  scaleEndPosition,
  baseScale,
  rotationAmount,
  blurAmount,
  useWindowScroll,
  calculateProgress,
  parsePercentage,
  getScrollData,
  getElementOffset,
])

Transform features:

  • Scale animations: Cards scale down as they stack
  • Rotation effects: Optional rotation for dynamic stacking
  • Blur effects: Depth-of-field blur for stacked cards
  • Smooth transitions: Optimized transform calculations
  • Performance: Only updates when values actually change

Performance Tip

The component only applies transforms when values actually change, using a comparison system to avoid unnecessary DOM updates and ensure smooth 60fps animations.

Step 4: Usage Examples & Implementation

Now let's see how to use the ScrollStack component with practical examples:

import ScrollStack, { ScrollStackItem } from './components/ScrollStack'

export default function App() {
  return (
    <ScrollStack
      itemDistance={120}
      itemScale={0.05}
      itemStackDistance={40}
      stackPosition="25%"
      baseScale={0.8}
      rotationAmount={2}
      blurAmount={1}
    >
      <ScrollStackItem>
        <div className="card-content">
          <h2>Card 1</h2>
          <p>This is the first card in the stack</p>
              </div>
      </ScrollStackItem>
      
      <ScrollStackItem>
        <div className="card-content">
          <h2>Card 2</h2>
          <p>This card will stack behind the first one</p>
              </div>
      </ScrollStackItem>
      
      <ScrollStackItem>
        <div className="card-content">
          <h2>Card 3</h2>
          <p>The final card in our stack</p>
              </div>
      </ScrollStackItem>
    </ScrollStack>
  )
}

Usage features:

  • Simple API: Easy-to-use component with sensible defaults
  • Flexible configuration: Extensive customization options
  • TypeScript support: Full type safety and IntelliSense
  • Event callbacks: React to stack completion and other events
  • Custom styling: Complete control over appearance

Implementation Insight

The ScrollStack component provides a simple yet powerful API for creating engaging scroll-based card interactions with minimal setup required.

Props & Configuration

The ScrollStack component offers extensive customization options:

ScrollStack Props

PropTypeDefaultDescription
classNamestring''Additional CSS classes for the scroll container
childrenReactNode-ScrollStackItem components to be stacked
itemDistancenumber100Distance between cards in pixels
itemScalenumber0.03Scale increment for each stacked card
itemStackDistancenumber30Distance between cards when stacked
stackPositionstring'20%'Position where stacking begins (percentage or pixels)
scaleEndPositionstring'10%'Position where scaling ends
baseScalenumber0.85Base scale for the smallest card
scaleDurationnumber0.5Duration of scale animation
rotationAmountnumber0Rotation amount for stacked cards
blurAmountnumber0Blur amount for depth effect
useWindowScrollbooleanfalseUse window scroll instead of container scroll
onStackComplete() => void-Callback when all cards are stacked

ScrollStackItem Props

PropTypeDefaultDescription
itemClassNamestring''Additional CSS classes for the card
childrenReactNode-Content to display inside the card

Responsive Design

/* Mobile optimizations */
@media (max-width: 768px) {
  .scroll-stack-inner {
    padding: 10vh 2rem 30rem;
  }
  
  .scroll-stack-card {
    height: 15rem;
    padding: 2rem;
    margin: 20px 0;
  }
}

@media (max-width: 480px) {
  .scroll-stack-inner {
    padding: 5vh 1rem 20rem;
  }
  
  .scroll-stack-card {
    height: 12rem;
    padding: 1.5rem;
    margin: 15px 0;
  }
  
  /* Reduce blur effects on mobile for better performance */
  .scroll-stack-card {
    filter: none !important;
  }
}

Responsive features:

  • Mobile optimization: Reduced padding and card sizes for mobile
  • Performance considerations: Disabled blur effects on small screens
  • Touch-friendly: Optimized spacing for touch interactions
  • Adaptive layouts: Responsive padding and sizing
  • Smooth scrolling: Maintained across all device sizes

Performance Note

On mobile devices, consider reducing blur effects and complex animations to ensure smooth scrolling and better performance.

Advanced Usage Examples

Here are some advanced usage patterns for the ScrollStack component:

import { useState, useEffect } from 'react'
import ScrollStack, { ScrollStackItem } from './components/ScrollStack'

export default function DynamicScrollStack() {
  const [cards, setCards] = useState([])

  useEffect(() => {
    // Load cards from API
    fetchCards().then(setCards)
  }, [])

  return (
    <ScrollStack
      onStackComplete={() => console.log('Stack animation complete!')}
      rotationAmount={1.5}
      blurAmount={1.2}
    >
      {cards.map((card, index) => (
        <ScrollStackItem key={card.id} itemClassName={`card-${index}`}>
          <div className="dynamic-card">
            <img src={card.image} alt={card.title} />
            <h3>{card.title}</h3>
            <p>{card.description}</p>
    </div>
        </ScrollStackItem>
      ))}
    </ScrollStack>
  )
}

Performance Considerations

  • Lenis optimization: Efficient smooth scrolling with requestAnimationFrame
  • Transform-based animations: GPU-accelerated performance
  • Memory management: Proper cleanup of Lenis instances
  • Scroll optimization: Only updates when values actually change
  • Mobile performance: Reduced effects on smaller screens

Browser Support

  • Modern browsers: Full support with all animations
  • Mobile browsers: Optimized performance with touch support
  • Fallbacks: Graceful degradation for older browsers
  • Touch devices: Enhanced touch interactions with Lenis

Conclusion

The ScrollStack component provides a beautiful and performant way to create engaging card stacking effects that respond to scroll. With its Lenis integration, transform-based animations, and extensive customization options, it's perfect for creating modern, interactive web experiences.

Key achievements:

  • Smooth scrolling: Lenis integration for buttery-smooth scroll experience
  • Card stacking: Beautiful transform-based stacking animations
  • Performance optimized: Efficient animations with proper cleanup
  • Responsive design: Works seamlessly across all devices
  • Customizable: Extensive configuration options for different use cases
  • TypeScript support: Full type safety and developer experience

This component demonstrates modern scroll-based interactions, performance optimization techniques, and React best practices to create truly engaging user experiences.


On this page