Scroll Stack
Create a smooth scrolling card stack effect with Lenis smooth scrolling and transform-based animations
Performance
GPU-accelerated animations with Lenis smooth scrolling for buttery-smooth 60fps performance across all devices.
Customizable
Extensive configuration options including scale, rotation, blur effects, and stacking behavior to match your design needs.
Responsive
Optimized for all screen sizes with touch-friendly interactions and adaptive layouts that work seamlessly on mobile and desktop.
Beautiful
Create stunning scroll-based interactions with smooth stacking animations, depth effects, and modern visual design patterns.
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-appInstall the required dependencies:
npm install lenis
npm install -D tailwindcss postcss autoprefixerDependencies 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.cssStep 1: Component Structure & Lenis Setup
Let's start by creating the foundational structure with proper Lenis smooth scrolling integration:
'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.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
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | '' | Additional CSS classes for the scroll container |
children | ReactNode | - | ScrollStackItem components to be stacked |
itemDistance | number | 100 | Distance between cards in pixels |
itemScale | number | 0.03 | Scale increment for each stacked card |
itemStackDistance | number | 30 | Distance between cards when stacked |
stackPosition | string | '20%' | Position where stacking begins (percentage or pixels) |
scaleEndPosition | string | '10%' | Position where scaling ends |
baseScale | number | 0.85 | Base scale for the smallest card |
scaleDuration | number | 0.5 | Duration of scale animation |
rotationAmount | number | 0 | Rotation amount for stacked cards |
blurAmount | number | 0 | Blur amount for depth effect |
useWindowScroll | boolean | false | Use window scroll instead of container scroll |
onStackComplete | () => void | - | Callback when all cards are stacked |
ScrollStackItem Props
| Prop | Type | Default | Description |
|---|---|---|---|
itemClassName | string | '' | Additional CSS classes for the card |
children | ReactNode | - | 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.
Related Resources
Lenis Documentation
Learn more about Lenis smooth scrolling library used in this component
CSS Transform Guide
Understanding CSS transform property for performant animations
Next.js Documentation
Official Next.js documentation for App Router and client components
Web Animations Best Practices
Learn about performance optimization for web animations