Card Rotation Component

A beautiful, responsive card rotation with auto-rotation and smooth animations.

Fully responsiveNext.jsFramer MotionTailwind CSS
82% MATCHED
CM

Camille Mercado

Marketing Specialist

$1,780 /mo2-5 years
Activity Title
86% MATCHED
MR

Michaela Reyes

Sales Manager

$1,680 /mo2-5 years
Activity Title
84% MATCHED
JS

Jethro Soriano

Graphic Designer

$1,980 /mo2-5 years
Activity Title

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-merge
2

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

PropTypeDefaultDescription
profilesProfile[]requiredArray of profile data
autoRotatebooleantrueAuto-rotate cards on hover
rotateIntervalnumber1500Rotation speed in milliseconds
onCardClick(profile) => voidundefinedClick handler callback