Card Rotation Component
A beautiful, responsive card rotation with auto-rotation and smooth animations.
Fully responsive•Next.js•Framer Motion•Tailwind CSS
82% MATCHED
Camille Mercado
Marketing Specialist
$1,780 /mo2-5 years
86% MATCHED
Michaela Reyes
Sales Manager
$1,680 /mo2-5 years
84% MATCHED
Jethro Soriano
Graphic Designer
$1,980 /mo2-5 years
Installation
Copy and paste the code below to get started
1
Install Dependencies
Terminal
1npm install framer-motion @radix-ui/react-avatar lucide-react clsx tailwind-merge2
Add Utils Function
lib/utils.ts
1import { clsx, type ClassValue } from "clsx"
2import { twMerge } from "tailwind-merge"
3
4export function cn(...inputs: ClassValue[]) {
5 return twMerge(clsx(inputs))
6}3
Copy Component File
components/CardStackComponent.tsx
1"use client";
2
3import { useState, useEffect } from "react";
4import { motion } from "framer-motion";
5import * as AvatarPrimitive from "@radix-ui/react-avatar";
6import { ChevronRight } from "lucide-react";
7
8// ============================================================================
9// TYPES
10// ============================================================================
11
12export interface Profile {
13 id: number;
14 name: string;
15 title: string;
16 salary: string;
17 experience: string;
18 match: number;
19 avatar: string;
20}
21
22export interface CardRotationComponentProps {
23 profiles: Profile[];
24 autoRotate?: boolean;
25 rotateInterval?: number;
26 onCardClick?: (profile: Profile) => void;
27}
28
29// ============================================================================
30// CARD ROTATION COMPONENT
31// ============================================================================
32
33export default function CardRotationComponent({
34 profiles,
35 autoRotate = true,
36 rotateInterval = 1500,
37 onCardClick
38}: CardRotationComponentProps) {
39 const [cards, setCards] = useState(profiles);
40 const [isHovering, setIsHovering] = useState(false);
41
42 const handleNext = () => {
43 setCards((prev) => {
44 const newCards = [...prev];
45 const first = newCards.shift();
46 if (first) newCards.push(first);
47 return newCards;
48 });
49 };
50
51 const handleCardClick = (profile: Profile) => {
52 handleNext();
53 onCardClick?.(profile);
54 };
55
56 useEffect(() => {
57 let interval: NodeJS.Timeout;
58
59 if (autoRotate && isHovering) {
60 interval = setInterval(() => {
61 handleNext();
62 }, rotateInterval);
63 }
64
65 return () => {
66 if (interval) clearInterval(interval);
67 };
68 }, [isHovering, autoRotate, rotateInterval]);
69
70 return (
71 <div
72 className="relative w-full max-w-[95%] sm:max-w-[900px] mx-auto px-2 sm:px-0"
73 onMouseEnter={() => setIsHovering(true)}
74 onMouseLeave={() => setIsHovering(false)}
75 >
76 {/* Grey outer container */}
77 <div
78 className="rounded-xl p-2 sm:p-3 w-full relative"
79 style={{
80 background: '#E5E7EB',
81 boxShadow: `
82 inset 1px 0 0 0 #9CA3AF,
83 inset -1px 0 0 0 #9CA3AF,
84 0 8px 24px rgba(0, 0, 0, 0.12)
85 `,
86 }}
87 >
88 {/* White horizontal lines with gradient fade - top and bottom */}
89 <div
90 className="absolute top-0 left-[16px] sm:left-[24px] right-[16px] sm:right-[24px] h-[1px]"
91 style={{
92 background: 'linear-gradient(to right, transparent 0%, #FFFFFF 10%, #FFFFFF 90%, transparent 100%)'
93 }}
94 />
95 <div
96 className="absolute bottom-0 left-[16px] sm:left-[24px] right-[16px] sm:right-[24px] h-[1px]"
97 style={{
98 background: 'linear-gradient(to right, transparent 0%, #FFFFFF 10%, #FFFFFF 90%, transparent 100%)'
99 }}
100 />
101
102 {/* Main white container - Responsive height */}
103 <div
104 className="bg-white rounded-xl px-3 sm:px-6 py-12 sm:py-16 overflow-hidden relative z-10 h-[360px] sm:h-[420px]"
105 style={{
106 boxShadow: `
107 inset 1px 0 0 0 #D1D5DB,
108 inset -1px 0 0 0 #D1D5DB
109 `,
110 }}
111 >
112 {/* Light grey horizontal lines with gradient fade - top and bottom */}
113 <div
114 className="absolute top-0 left-[12px] sm:left-[20px] right-[12px] sm:right-[20px] h-[1px]"
115 style={{
116 background: 'linear-gradient(to right, transparent 0%, #F9FAFB 10%, #F9FAFB 90%, transparent 100%)'
117 }}
118 />
119 <div
120 className="absolute bottom-0 left-[12px] sm:left-[20px] right-[12px] sm:right-[20px] h-[1px]"
121 style={{
122 background: 'linear-gradient(to right, transparent 0%, #F9FAFB 10%, #F9FAFB 90%, transparent 100%)'
123 }}
124 />
125 {/* Profile cards stack inside */}
126 <div className="relative h-full flex items-center justify-center">
127 {cards.map((profile, index) => {
128 const isFocused = index === 1;
129 const isTop = index === 0;
130 const isBottom = index === 2;
131
132 let zIndex = isFocused ? 5 : 1;
133 let scale = isFocused ? 1.08 : 0.88;
134 let yOffset = 0;
135 let blur = 0;
136
137 // Position cards so 50% is clipped by container overflow
138 if (isTop) {
139 yOffset = -170;
140 blur = 2;
141 } else if (isBottom) {
142 yOffset = 170;
143 blur = 2;
144 }
145
146 if (index > 2) {
147 return null;
148 }
149
150 return (
151 <motion.div
152 key={profile.id}
153 className="absolute"
154 style={{
155 zIndex,
156 }}
157 initial={false}
158 animate={{
159 scale,
160 y: yOffset,
161 filter: `blur(${blur}px)`,
162 }}
163 transition={{
164 duration: 0.7,
165 ease: [0.25, 0.1, 0.25, 1],
166 }}
167 >
168 <ProfileCard
169 profile={profile}
170 onClick={() => handleCardClick(profile)}
171 isTop={isFocused}
172 />
173 </motion.div>
174 );
175 })}
176 </div>
177 </div>
178 </div>
179 </div>
180 );
181}
182
183// ============================================================================
184// PROFILE CARD (Internal Component)
185// ============================================================================
186
187interface ProfileCardProps {
188 profile: Profile;
189 onClick?: () => void;
190 isTop?: boolean;
191}
192
193function ProfileCard({ profile, onClick }: ProfileCardProps) {
194 return (
195 <div className="w-[280px] sm:w-[400px] mx-auto bg-white rounded-[6px] shadow-[2px_4px_16px_rgba(0,0,0,0.12)] p-2 sm:p-3 relative">
196 {/* Badge - Top Right */}
197 <div className="absolute top-2 right-2 sm:top-3 sm:right-3">
198 <MatchBadge match={profile.match} />
199 </div>
200
201 {/* Header Section */}
202 <header className="flex items-center gap-2 sm:gap-2.5 mb-2 sm:mb-2.5 pr-16 sm:pr-20">
203 <ProfileAvatar name={profile.name} avatar={profile.avatar} />
204 <ProfileInfo name={profile.name} title={profile.title} />
205 </header>
206
207 {/* Details Section */}
208 <div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-2.5 flex-wrap">
209 <ProfileDetails salary={profile.salary} experience={profile.experience} />
210 </div>
211
212 {/* Footer Section */}
213 <footer className="flex items-center justify-between">
214 <span className="text-[10px] sm:text-[11px] text-[#9CA3AF] font-normal">Activity Title</span>
215 <ActionButton onClick={onClick} />
216 </footer>
217 </div>
218 );
219}
220
221// ============================================================================
222// SUB-COMPONENTS
223// ============================================================================
224
225function ProfileAvatar({ name, avatar }: { name: string; avatar: string }) {
226 return (
227 <AvatarPrimitive.Root className="relative flex w-[32px] h-[32px] sm:w-[36px] sm:h-[36px] shrink-0 overflow-hidden rounded-full">
228 <AvatarPrimitive.Image
229 src={avatar}
230 alt={name}
231 className="aspect-square h-full w-full object-cover"
232 />
233 <AvatarPrimitive.Fallback className="flex h-full w-full items-center justify-center rounded-full bg-gray-200 text-gray-600 text-xs sm:text-sm font-medium">
234 {name.split(' ').map(n => n[0]).join('')}
235 </AvatarPrimitive.Fallback>
236 </AvatarPrimitive.Root>
237 );
238}
239
240function ProfileInfo({ name, title }: { name: string; title: string }) {
241 return (
242 <div className="flex-1 min-w-0">
243 <h2 className="text-[13px] sm:text-[14px] font-semibold text-[#111827] leading-tight mb-0.5">
244 {name}
245 </h2>
246 <p className="text-[11px] sm:text-[12px] text-[#6B7280] font-normal leading-tight">
247 {title}
248 </p>
249 </div>
250 );
251}
252
253function ProfileDetails({ salary, experience }: { salary: string; experience: string }) {
254 return (
255 <>
256 <span className="text-[11px] sm:text-[12px] font-semibold text-[#111827]">
257 {salary}
258 </span>
259 <span className="text-[11px] sm:text-[12px] font-normal text-[#6B7280]">
260 {experience}
261 </span>
262 </>
263 );
264}
265
266function MatchBadge({ match }: { match: number }) {
267 return (
268 <span className="bg-[#ECFDF5] text-[#059669] px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-[4px] text-[9px] sm:text-[10px] font-bold uppercase tracking-wide">
269 {match}% MATCHED
270 </span>
271 );
272}
273
274function ActionButton({ onClick }: { onClick?: () => void }) {
275 return (
276 <button
277 onClick={onClick}
278 className="inline-flex items-center justify-center rounded-full bg-gray-200 hover:bg-gray-300 h-5 w-5 sm:h-6 sm:w-6 p-0 transition-colors"
279 >
280 <ChevronRight className="h-2.5 w-2.5 sm:h-3 sm:w-3 text-gray-600" />
281 </button>
282 );
283}Usage Example
app/page.tsx
1import CardRotationComponent from "@/components/CardRotationComponent";
2
3const profiles = [
4 {
5 id: 1,
6 name: "Camille Mercado",
7 title: "Marketing Specialist",
8 salary: "$1,780 /mo",
9 experience: "2-5 years",
10 match: 82,
11 avatar: "https://i.pravatar.cc/150?img=1",
12 },
13 // Add more profiles...
14];
15
16export default function MyPage() {
17 return (
18 <div className="min-h-screen flex items-center justify-center p-4">
19 <CardStackComponent
20 profiles={profiles}
21 onCardClick={(profile) => console.log('Clicked:', profile.name)}
22 />
23 </div>
24 );
25}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| profiles | Profile[] | required | Array of profile data |
| autoRotate | boolean | true | Auto-rotate cards on hover |
| rotateInterval | number | 1500 | Rotation speed in milliseconds |
| onCardClick | (profile) => void | undefined | Click handler callback |