STUDY / 004 · Interactions
Card stack
Layers of ideas. A new one with every tap.
Preview
Live preview · Go ahead, interact with it
Code
"use client";
import { ArrowUpRight, Layers } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { cn } from "@/lib/utils";
const cards = [
{
title: "Small details.",
subtitle: "Big difference.",
color: "bg-[#eae6df]",
},
{
title: "Less, but better.",
subtitle: "Room to breathe.",
color: "bg-[#e4e9df]",
},
{
title: "Made to move.",
subtitle: "A work in progress.",
color: "bg-[#e1e7ee]",
},
];
export default function CardStack({ className }: { className?: string }) {
const [front, setFront] = useState(0);
const reduced = useReducedMotion();
return (
<button
type="button"
aria-label="Cycle card stack"
onClick={() => setFront((front + 1) % cards.length)}
className={cn(
"relative h-48 w-60 text-left outline-offset-12",
className,
)}
>
{cards.map((card, index) => {
const position = (index - front + cards.length) % cards.length;
return (
<motion.span
key={card.title}
animate={{
y: reduced ? 0 : -position * 12,
scale: 1 - position * 0.06,
rotate: reduced ? 0 : position * -3,
}}
transition={{
type: "spring",
duration: reduced ? 0 : 0.4,
bounce: 0.12,
}}
style={{ zIndex: cards.length - position }}
className={cn(
"absolute inset-0 flex flex-col justify-between rounded-xl border border-black/10 p-5 shadow-[0_4px_12px_-6px_#0002]",
"text-neutral-900",
card.color,
)}
>
<span className="flex justify-between">
<Layers size={19} strokeWidth={1.5} />
<ArrowUpRight size={15} />
</span>
<span>
<span className="block text-xl font-medium tracking-tight">
{card.title}
</span>
<span className="mt-1 block text-xs text-neutral-500">
{card.subtitle}
</span>
</span>
<span className="font-mono text-[9px] text-neutral-500">
STUDY 00{index + 1}
</span>
</motion.span>
);
})}
</button>
);
}
React · TypeScript · Tailwind CSS
Copy the component and the cn helper into your project.
bun add motion lucide-react clsx tailwind-merge