We shipped our launch page in an afternoon.
Card 1 of 4
Customize
Usage
import { CardStack } from "@/components/motionkit/card-stack";
<CardStack
label="Testimonials"
cards={testimonials.map((item) => (
<blockquote key={item.name}>{item.quote}</blockquote>
))}
/>Motion
The exact curves this component runs, plotted with Motion's own maths.
Reshuffle
Top card flung or arrow key
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/card-stack.tsx.
"use client";
import { useState, type KeyboardEvent, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
type CardStackProps = {
/** One entry per card, front to back. */
cards: ReactNode[];
/** Vertical gap between stacked cards, in pixels. */
offset?: number;
/** Drag distance (px) that sends the top card to the back. */
threshold?: number;
/** Accessible name for the stack, e.g. "Testimonials". */
label?: string;
className?: string;
};
export function CardStack({ cards, offset = 14, threshold = 90, label = "Cards", className = "" }: CardStackProps) {
// order[0] is the card on top
const [order, setOrder] = useState(() => cards.map((_, index) => index));
const reduceMotion = useReducedMotion();
const visible = Math.min(order.length, 4);
const next = () => setOrder((current) => [...current.slice(1), current[0]]);
const previous = () => setOrder((current) => [current[current.length - 1], ...current.slice(0, -1)]);
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === "ArrowRight") next();
else if (event.key === "ArrowLeft") previous();
else return;
event.preventDefault();
}
return (
<div
role="group"
aria-roledescription="card stack"
aria-label={`${label}. Use the left and right arrow keys to shuffle.`}
tabIndex={0}
onKeyDown={handleKeyDown}
className={`relative rounded-2xl focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#5FAEB4] ${className}`}
style={{ paddingBottom: offset * (visible - 1) }}
>
{/* the grid stacks every card in the same cell so the container sizes to the tallest */}
<div className="grid">
{order.map((cardIndex, depth) => {
const isTop = depth === 0;
return (
<motion.div
key={cardIndex}
aria-hidden={!isTop}
className="col-start-1 row-start-1 rounded-2xl border border-white/10 bg-[#141B1B] p-6 text-[#F6F1E8] shadow-xl shadow-black/40"
style={{ zIndex: cards.length - depth, cursor: isTop ? "grab" : "default" }}
initial={false}
animate={{
y: Math.min(depth, visible - 1) * offset,
scale: 1 - Math.min(depth, visible - 1) * 0.05,
opacity: depth < visible ? 1 : 0,
x: 0,
rotate: 0,
}}
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 300, damping: 28 }}
drag={isTop ? "x" : false}
dragSnapToOrigin
whileDrag={{ cursor: "grabbing" }}
onDragEnd={(_, info) => {
if (Math.abs(info.offset.x) > threshold || Math.abs(info.velocity.x) > 600) next();
}}
>
{cards[cardIndex]}
</motion.div>
);
})}
</div>
<p className="sr-only" aria-live="polite">
Card {order[0] + 1} of {cards.length}
</p>
</div>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| cards | ReactNode[] | — | One entry per card, front to back. |
| offset | number | 14 | Vertical gap between stacked cards, in pixels. |
| threshold | number | 90 | Drag distance (px) that sends the top card to the back. |
| label | string | "Cards" | Accessible name for the stack. |
Realtime sync
Move your cursor across this card.
Spotlight Card
A card with a soft glow that tracks the pointer across its surface.
Edge functions
Tilt me with your cursor.
Tilt Card
A 3D card that tilts toward the pointer, with an optional moving glare.
Launch kit
Tilt me to see the layers.
Depth Card
A card that tilts toward the cursor while its layers float at different depths, so each moves at its own speed.