Stacked Cards

An interactive 3D stacked deck of cards with responsive fan-out hover and click-to-cycle animation.

ReactTailwind CSSFramer Motion
Dependenciesframer-motion, lucide-react
npm install framer-motion lucide-react

Preview

Fast

Instant Integration

Drop directly into your codebase with self-contained styles and standard dependencies.

Card 1 of 3Click to stack
Smooth

Fluid Micro-Interactions

Tuned springs and layout animations using Framer Motion that respond naturally to user gestures.

Card 2 of 3Click to stack
Solid

Production Ready

Built with full TypeScript contracts, keyboard accessibility, and clean semantic markup.

Card 3 of 3Click to stack

Hover to fan out · Click to cycle stack

Source Code

stacked-cards.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
"use client";

import React, { useState } from "react";
import { motion } from "framer-motion";
import { Layers, ArrowRight, ShieldCheck, Zap, Sparkles } from "lucide-react";

export interface CardItem {
  id: string;
  title: string;
  subtitle: string;
  description: string;
  badge: string;
  icon: React.ComponentType<{ className?: string }>;
}

export function StackedCards() {
  const [cards, setCards] = useState<CardItem[]>([
    {
      id: "1",
      title: "Instant Integration",
      subtitle: "Zero Configuration Required",
      description: "Drop directly into your codebase with self-contained styles and standard dependencies.",
      badge: "Fast",
      icon: Zap,
    },
    {
      id: "2",
      title: "Fluid Micro-Interactions",
      subtitle: "Physics-Driven Spring Motion",
      description: "Tuned springs and layout animations using Framer Motion that respond naturally to user gestures.",
      badge: "Smooth",
      icon: Sparkles,
    },
    {
      id: "3",
      title: "Production Ready",
      subtitle: "Type-Safe & Accessible",
      description: "Built with full TypeScript contracts, keyboard accessibility, and clean semantic markup.",
      badge: "Solid",
      icon: ShieldCheck,
    },
  ]);
  const [isHovered, setIsHovered] = useState(false);

  const cycleCards = () => {
    setCards((prev) => {
      const [first, ...rest] = prev;
      return [...rest, first];
    });
  };

  return (
    <div className="flex flex-col items-center justify-center gap-6 py-4">
      <div
        className="relative w-72 sm:w-80 h-48 cursor-pointer"
        onMouseEnter={() => setIsHovered(true)}
        onMouseLeave={() => setIsHovered(false)}
        onClick={cycleCards}
      >
        {cards.map((card, index) => {
          const Icon = card.icon;
          const yOffset = index * 12;
          const scale = 1 - index * 0.05;
          const rotation = isHovered ? (index - 1) * 6 : 0;
          const xOffset = isHovered ? (index - 1) * 20 : 0;

          return (
            <motion.div
              key={card.id}
              initial={false}
              animate={{
                y: yOffset,
                x: xOffset,
                scale: scale,
                rotateZ: rotation,
                zIndex: cards.length - index,
              }}
              transition={{
                type: "spring",
                stiffness: 350,
                damping: 25,
              }}
              className="absolute inset-0 rounded-2xl p-5 border border-zinc-200/80 dark:border-zinc-800/80
                bg-white dark:bg-zinc-900 shadow-lg shadow-black/5 dark:shadow-black/20
                flex flex-col justify-between select-none"
            >
              <div>
                <div className="flex items-center justify-between mb-2">
                  <div className="w-8 h-8 rounded-lg bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-900 dark:text-zinc-100">
                    <Icon className="w-4 h-4" />
                  </div>
                  <span className="text-[10px] font-semibold tracking-wider uppercase px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300">
                    {card.badge}
                  </span>
                </div>
                <h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
                  {card.title}
                </h3>
                <p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1 line-clamp-2">
                  {card.description}
                </p>
              </div>

              <div className="flex items-center justify-between text-[11px] text-zinc-400 pt-2 border-t border-zinc-100 dark:border-zinc-800">
                <span>Card {index + 1} of {cards.length}</span>
                <span className="flex items-center gap-1 font-medium text-zinc-700 dark:text-zinc-300">
                  Click to stack <ArrowRight className="w-3 h-3" />
                </span>
              </div>
            </motion.div>
          );
        })}
      </div>

      <p className="text-xs text-zinc-400 flex items-center gap-1.5">
        <Layers className="w-3.5 h-3.5" />
        Hover to fan out · Click to cycle stack
      </p>
    </div>
  );
}

Usage

usage.tsx
1
<StackedCards />

Types

types.ts
1
2
3
4
5
6
7
8
export interface CardItem {
  id: string;
  title: string;
  subtitle: string;
  description: string;
  badge: string;
  icon: React.ComponentType<{ className?: string }>;
}