Docs
Scrolling Cards
Scrolling Cards
A dynamic, scroll-triggered component that creates an engaging card reveal animation sequence
Card 1
Reach fot the stars

Card 2
Reach fot the stars

Card 3
Reach fot the stars

Card 4
Reach fot the stars

Card 5
Reach fot the stars

Install the following dependencies:
pnpm add gsap
Make a file for cn function and match the import afterwards
import clsx, { ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export const cn = (...classes: ClassValue[]) => twMerge(clsx(...classes))
Make a file and copy paste this code in a file with name scrolling-cards.tsx
"use client"
import { ReactElement, RefObject, useEffect, useRef, useState } from "react"
import { useGSAP } from "@gsap/react"
import ScrollTrigger from "gsap/ScrollTrigger"
import gsap from "gsap"
gsap.registerPlugin(ScrollTrigger)
interface CardSliderProps {
cards: {
card: ReactElement
rotate: number
transformOrigin?: string
}[]
cardWidth: number
top?: number
left?: number
/**
* Should be multiple of 100 eg. 100,200....
*/
animationLength?: number
scrollerRef?: RefObject<HTMLElement>
}
function ScrollingCards({
cards,
cardWidth,
top = 45,
left = 20,
animationLength = 300,
scrollerRef,
}: CardSliderProps) {
const sectionRef = useRef<HTMLElement>(null)
const cardsRef = useRef<HTMLDivElement[]>([])
useGSAP(() => {
if (!sectionRef.current || !cardsRef.current) return
const unitLeftDis = (50 - left) / cards.length
// Set initial position for all cards (below viewport)
gsap.set(cardsRef.current, {
top: window.innerHeight + 300,
rotate: (i) => `${cards[i].rotate}deg`,
})
const innerTl = gsap.timeline({
scrollTrigger: {
trigger: sectionRef.current,
start: "center center", // Start when section center hits viewport center
end: `+=${animationLength}%`, // End after scrolling 300% of section height
scrub: true, // Smooth scrubbing effect
markers: false, // Set to
},
})
// Create timeline for sequential animation
const tl = gsap.timeline({
scrollTrigger: {
trigger: sectionRef.current,
start: "center center", // Start when section center hits viewport center
end: "+=300%", // End after scrolling 300% of section height
pin: true, // Pin the section while animation plays
pinSpacing: true,
scrub: 1, // Smooth scrubbing effect
markers: false,
onUpdate: (self) => {
const direction = self.direction
const topCards = cardsRef.current.filter((card) => {
if (
card.offsetTop /
(scrollerRef?.current?.getBoundingClientRect().height ||
window.innerHeight) <=
top / 100
)
return true
else return false
})
if (direction == 1) {
innerTl.clear()
innerTl.to(
topCards,
{
left: (i) => {
return `${i * unitLeftDis + left}%`
},
},
"<"
)
} else {
innerTl.clear()
innerTl.to(topCards, {
left: "50%",
})
}
},
},
})
// Add each card to the timeline with sequential animation
cardsRef.current.forEach((card) => {
if (!card) return
tl.to(
card,
{
top: `${top}%`,
ease: "none",
},
">"
) // Stagger the animations
})
}, [cards, cardsRef.current])
return (
<section
ref={sectionRef}
className="h-screen relative flex items-center justify-center overflow-hidden"
>
{cards.map((item, index) => (
<div
key={index}
ref={(el) => {
if (el) cardsRef.current[index] = el
}}
style={{
rotate: `${item.rotate}deg`,
transformOrigin: `${
item.transformOrigin ?? `${index % 2 === 0 ? "left" : "right"}`
}`,
width: `${cardWidth}px`,
position: "absolute",
}}
className="transition-all -translate-x-1/2 -translate-y-1/2 left-1/2 top-full"
>
{item.card}
</div>
))}
</section>
)
}
function SnappingScrollingCards({
cards,
cardWidth,
top = 35,
left = 30,
animationLength = 400,
scrollerRef,
}: CardSliderProps) {
const sectionRef = useRef<HTMLElement>(null)
const cardsRef = useRef<HTMLDivElement[]>([])
useGSAP(() => {
if (!sectionRef.current || cardsRef.current.some((ref) => !ref)) return
const unitLeftDis = (50 - left) / (cards.length - 1 || 1)
// Calculate maximum rotation to adjust starting position
const maxRotation = Math.max(...cards.map((card) => Math.abs(card.rotate)))
const extraPadding = maxRotation * 2 // Add extra padding based on rotation
// Reset all cards to initial position - move them further down to account for rotation
gsap.set(cardsRef.current, {
top: window.innerHeight + 300 + extraPadding,
left: "50%",
rotate: (i) => `${cards[i]?.rotate || 0}deg`,
opacity: 0, // Start with opacity 0
clearProps: "none", // Clear any previously set props
})
// Create main scroll trigger
const st = ScrollTrigger.create({
trigger: sectionRef.current,
start: "center center",
end: `+=${animationLength}%`,
pin: true,
scrub: 1,
onUpdate: (self) => {
// Calculate current scroll progress (0-1)
const progress = self.progress
// Total scroll is divided into sections:
// - First 70% of scroll brings cards up sequentially
// - Remaining 30% ensures all cards reach final horizontal position
const verticalSection = 0.7 // 70% of scroll dedicated to vertical movement
cardsRef.current.forEach((card, i) => {
if (!card) return
// Calculate when this card should start its vertical animation
// (distributed evenly across the first 70% of scroll)
const cardStartPoint = (i / cards.length) * verticalSection
// Calculate progress of this card's animation sequence (0-1)
let cardProgress =
(progress - cardStartPoint) / (verticalSection / cards.length)
cardProgress = Math.max(0, Math.min(1, cardProgress))
// Calculate vertical position
const verticalProgress = Math.min(1, cardProgress * 2) // Complete vertical movement in first half of card's sequence
const startY = window.innerHeight + 300 + extraPadding
const endY = (window.innerHeight * top) / 100
const topPosition = startY - (startY - endY) * verticalProgress
// Calculate horizontal position (starts when vertical is halfway done)
let horizontalProgress = 0
if (cardProgress > 0.5) {
// Map 0.5-1 to 0-1 for horizontal animation
horizontalProgress = (cardProgress - 0.5) * 2
// Use eased progress based on overall scroll progress to ensure all cards finish together
// When overall progress reaches 1, all cards should be at their final position
const masterProgress = Math.min(1, progress / 1)
horizontalProgress = Math.min(horizontalProgress, masterProgress)
}
// Calculate final left position
const leftPos =
50 - (50 - (left + i * unitLeftDis)) * horizontalProgress
// Apply transforms directly
gsap.set(card, {
top: topPosition,
left: `${leftPos}%`,
opacity: cardProgress > 0 ? 1 : 0, // Fade in when animation starts
})
})
},
})
return () => {
st.kill()
}
}, [cards, cardWidth, top, left])
return (
<section
ref={sectionRef}
className="h-screen relative flex items-center justify-center overflow-hidden"
>
{cards.map((item, index) => (
<div
key={index}
ref={(el) => {
if (el) cardsRef.current[index] = el
}}
style={{
rotate: `${item.rotate}deg`,
transformOrigin:
item.transformOrigin || (index % 2 === 0 ? "left" : "right"),
width: `${cardWidth}px`,
position: "absolute",
}}
className="transition-all -translate-x-1/2 -translate-y-1/2"
>
{item.card}
</div>
))}
</section>
)
}
export { ScrollingCards, SnappingScrollingCards }
Usage
Scrolling Cards With Snapping Effect
Card 1
Reach fot the stars

Card 2
Reach fot the stars

Card 3
Reach fot the stars

Card 4
Reach fot the stars

Card 5
Reach fot the stars

Props
Name | Type | Description |
---|---|---|
cards * | { card: ReactElement; rotate: number; transformOrigin?: string }[] | Array of card objects, each containing a React element, rotation angle, and optional transform origin. |
cardWidth * | number | The width of each card in pixels. |
top | number | The top position of the card slider. Optional. |
left | number | The left position of the card slider. Optional. |
animationLength | number | Duration of the animation in milliseconds. Should be a multiple of 100. Default is 100 . Optional. |
scrollerRef | RefObject<HTMLElement> | A reference to the scrolling container element. Optional. |
Note: Props marked with
*
are required.