Scrolling Stacked Cards
Animate a stack of cards on scroll. Perfect for showcasing features or steps in a sequential, engaging way.
Key Component Features
This is a live demonstration. Scroll down to see it in action.
Fully responsive by default.
Built with Tailwind CSS for easy styling.
Animated with GSAP for smooth performance.
Type-safe with TypeScript.
Composable and highly reusable.
tsx
1import { StackedCards } from '@/components/ui/stacked-cards';
2
3const featuresData = [
4 { id: 'feat-1', content: 'Fully responsive by default.' },
5 { id: 'feat-2', content: 'Built with Tailwind CSS for easy styling.' },
6 { id: 'feat-3', content: 'Animated with GSAP for smooth performance.' },
7 { id: 'feat-4', content: 'Type-safe with TypeScript.' },
8 { id: 'feat-5', content: 'Composable and highly reusable.' },
9];
10
11export function ComponentPreview() {
12 return (
13 <StackedCards
14 title="Key Component Features"
15 subtitle="This is a live demonstration. Scroll down to see it in action."
16 cards={featuresData}
17 />
18 );
19}Installation
First, install the necessary dependencies for animation and icons.
tsx
1npm install gsap @gsap/react react-iconsNext, copy and paste the following component code into your project.
components/ui/stacked-cards.tsx
1'use client';
2
3import React, { useRef } from 'react';
4import { gsap } from 'gsap';
5import { ScrollTrigger } from 'gsap/ScrollTrigger';
6import { useGSAP } from '@gsap/react';
7
8gsap.registerPlugin(ScrollTrigger);
9
10
11
12interface CardData {
13 id: string | number;
14 content: React.ReactNode;
15}
16
17
18interface ScrollingStackedCardsProps {
19 title: string;
20 subtitle: string;
21 cards: CardData[];
22 BackgroundIcon?: React.ComponentType<{ className?: string }>;
23 className?: string;
24}
25
26
27export const StackedCards = ({
28 title,
29 subtitle,
30 cards,
31 BackgroundIcon,
32 className = '',
33}: ScrollingStackedCardsProps) => {
34 const mainRef = useRef<HTMLDivElement>(null);
35 const cardsRef = useRef<(HTMLDivElement | null)[]>([]);
36 cardsRef.current = [];
37
38 useGSAP(
39 () => {
40 if (cards.length === 0) return;
41
42 const cardElements = cardsRef.current.filter(c => c !== null) as HTMLDivElement[];
43
44 const scrollDuration = cards.length * 800;
45
46 const timeline = gsap.timeline({
47 scrollTrigger: {
48 trigger: mainRef.current,
49 pin: true,
50 scrub: 1,
51 start: 'top top',
52 end: `+=${scrollDuration}`,
53 markers: false,
54 },
55 });
56
57 timeline.to('.gsap-title', {
58 y: 20,
59 duration: 0.4,
60 });
61
62 cardElements.forEach((card, index) => {
63 if (index === cardElements.length - 1) return;
64
65 const animDuration = 1;
66 const opacityStartProgress = 0.7;
67 const opacityStartTimeOffset = animDuration * opacityStartProgress;
68 const opacityDuration = animDuration * (1 - opacityStartProgress);
69
70 timeline
71 .to(
72 card,
73 {
74 yPercent: -25,
75 scale: 1.5,
76 duration: animDuration,
77 ease: 'power1.inOut',
78 },
79 '<+0.5',
80 )
81 .to(
82 card,
83 {
84 opacity: 0,
85 duration: opacityDuration,
86 ease: 'power1.in',
87 },
88 `<+${opacityStartTimeOffset}`,
89 );
90 });
91 },
92 { scope: mainRef, dependencies: [cards] },
93 );
94
95 const addToRefs = (el: HTMLDivElement | null) => {
96 if (el && !cardsRef.current.includes(el)) {
97 cardsRef.current.push(el);
98 }
99 };
100
101 return (
102 <div ref={mainRef} className={`relative ${className}`}>
103 <div className="h-screen w-full flex flex-col items-center justify-center overflow-hidden">
104
105 <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
106 {BackgroundIcon && (
107 <BackgroundIcon className="text-[25rem] text-primary/5" />
108 )}
109 <div className="absolute inset-0 bg-radial-gradient from-primary/10 to-transparent to-70% rounded-full"></div>
110 </div>
111
112 <div className="gsap-title relative z-10 flex flex-col items-center text-center mb-16">
113 <h2 className="font-display text-4xl sm:text-7xl font-bold bg-clip-text text-transparent bg-gradient-to-b from-gray-50 to-gray-400">
114 {title}
115 </h2>
116 <p className="mt-4 max-w-md text-lg text-gray-400">{subtitle}</p>
117 </div>
118
119 <div className="relative w-11/12 md:w-4/6 h-96">
120 {cards.map((card, i) => (
121 <div
122 key={card.id}
123 ref={addToRefs}
124 className="absolute top-0 left-0 w-full h-full p-8 flex items-center justify-center
125 bg-blue-200/10 backdrop-blur-2xl rounded-3xl border border-white/10
126 shadow-2xl shadow-primary/10"
127 style={{
128 transform: `translateY(${i * 15}px) scale(${1 - i * 0.04})`,
129 zIndex: cards.length - i,
130 }}
131 >
132 <div className="text-3xl md:text-5xl font-bold text-center text-stone-300 leading-snug">
133 {card.content}
134 </div>
135 </div>
136 ))}
137 </div>
138 </div>
139 </div>
140 );
141};