Stacked Card
Original · freeA stack of cards that fans out on hover.
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/card-stacked.json"use client";
import { useCallback, useId, useMemo, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
type Accent = "indigo" | "violet" | "emerald" | "rose" | "amber" | "sky";
type Shot = {
id: string;
title: string;
place: string;
author: string;
img: string;
accent: Accent;
};
const ACCENTS: Record<
Accent,
{ dot: string; chip: string; ring: string }
> = {
indigo: {
dot: "bg-indigo-500",
chip: "bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
ring: "focus-visible:ring-indigo-500",
},
violet: {
dot: "bg-violet-500",
chip: "bg-violet-50 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300",
ring: "focus-visible:ring-violet-500",
},
emerald: {
dot: "bg-emerald-500",
chip: "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
ring: "focus-visible:ring-emerald-500",
},
rose: {
dot: "bg-rose-500",
chip: "bg-rose-50 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
ring: "focus-visible:ring-rose-500",
},
amber: {
dot: "bg-amber-500",
chip: "bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
ring: "focus-visible:ring-amber-500",
},
sky: {
dot: "bg-sky-500",
chip: "bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
ring: "focus-visible:ring-sky-500",
},
};
const DECK_A: Shot[] = [
{
id: "kyoto",
title: "Kyoto at Dawn",
place: "Fushimi Inari, JP",
author: "Aiko Tanaka",
img: "/img/gallery/g01.webp",
accent: "rose",
},
{
id: "aurora",
title: "Reykjavík Aurora",
place: "Þingvellir, IS",
author: "Erik Lindqvist",
img: "/img/gallery/g02.webp",
accent: "emerald",
},
{
id: "souk",
title: "Marrakech Souk",
place: "Medina, MA",
author: "Yara Benali",
img: "/img/gallery/g03.webp",
accent: "amber",
},
{
id: "patagonia",
title: "Patagonia Ridge",
place: "Torres del Paine, CL",
author: "Diego Alarcón",
img: "/img/gallery/g04.webp",
accent: "sky",
},
{
id: "lofoten",
title: "Lofoten Blue Hour",
place: "Reine, NO",
author: "Ingrid Solberg",
img: "/img/gallery/g05.webp",
accent: "indigo",
},
];
const DECK_B: Shot[] = [
{
id: "dunes",
title: "Erg Chebbi Dunes",
place: "Sahara, MA",
author: "Nadia El Fassi",
img: "/img/gallery/g06.webp",
accent: "amber",
},
{
id: "harbor",
title: "Nyhavn Harbor",
place: "Copenhagen, DK",
author: "Lucas Meyer",
img: "/img/gallery/g07.webp",
accent: "sky",
},
{
id: "terraces",
title: "Banaue Terraces",
place: "Ifugao, PH",
author: "Mateo Reyes",
img: "/img/gallery/g08.webp",
accent: "emerald",
},
{
id: "canyon",
title: "Antelope Canyon",
place: "Page, AZ",
author: "Sara Whitcomb",
img: "/img/gallery/g09.webp",
accent: "violet",
},
];
type SizeKey = "lg" | "sm";
const SIZES: Record<SizeKey, { w: number; h: number; fan: number; pad: number }> = {
lg: { w: 246, h: 322, fan: 152, pad: 104 },
sm: { w: 172, h: 226, fan: 108, pad: 88 },
};
type Slot = { x: number; y: number; rotate: number; scale: number; zIndex: number };
function slotStyle(
slot: number,
count: number,
expanded: boolean,
fan: number,
): Slot {
if (!expanded) {
return {
x: slot * 4,
y: slot * -12,
rotate: slot === 0 ? 0 : slot % 2 === 0 ? -slot * 1.8 : slot * 1.8,
scale: 1 - slot * 0.05,
zIndex: count - slot,
};
}
const mid = (count - 1) / 2;
const offset = slot - mid;
return {
x: offset * fan,
y: Math.abs(offset) * 16 - 6,
rotate: offset * 7,
scale: 1,
zIndex: count - Math.abs(Math.round(offset)),
};
}
function HeartIcon({ filled }: { filled: boolean }) {
return (
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill={filled ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8Z" />
</svg>
);
}
function ShuffleIcon() {
return (
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M16 3h5v5" />
<path d="M4 20 21 3" />
<path d="M21 16v5h-5" />
<path d="m15 15 6 6" />
<path d="M4 4l5 5" />
</svg>
);
}
function LayersIcon({ open }: { open: boolean }) {
return (
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
style={{ transform: open ? "rotate(180deg)" : "none", transition: "transform .2s" }}
>
<path d="m12 2 9 5-9 5-9-5 9-5Z" />
<path d="m3 12 9 5 9-5" />
<path d="m3 17 9 5 9-5" />
</svg>
);
}
function CardStack({
shots,
size,
label,
hint,
}: {
shots: Shot[];
size: SizeKey;
label: string;
hint: string;
}) {
const reduce = useReducedMotion();
const dims = SIZES[size];
const baseId = useId();
const byId = useMemo(() => {
const map: Record<string, Shot> = {};
for (const s of shots) map[s.id] = s;
return map;
}, [shots]);
const [order, setOrder] = useState<string[]>(() => shots.map((s) => s.id));
const [hovered, setHovered] = useState(false);
const [pinned, setPinned] = useState(false);
const [focused, setFocused] = useState(false);
const [liked, setLiked] = useState<Record<string, boolean>>({});
const expanded = hovered || pinned || focused;
const bringToFront = useCallback((id: string) => {
setOrder((prev) => [id, ...prev.filter((x) => x !== id)]);
}, []);
const shuffle = useCallback(() => {
setOrder((prev) => (prev.length < 2 ? prev : [...prev.slice(1), prev[0]]));
}, []);
const toggleLike = useCallback((id: string) => {
setLiked((prev) => ({ ...prev, [id]: !prev[id] }));
}, []);
const top = byId[order[0]];
const topAccent = ACCENTS[top.accent];
return (
<div className="flex flex-col items-center">
<div
className="relative flex w-full items-end justify-center"
style={{ height: dims.h + dims.pad }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocusCapture={() => setFocused(true)}
onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setFocused(false);
}
}}
>
{order.map((id, slot) => {
const shot = byId[id];
const st = slotStyle(slot, order.length, expanded, dims.fan);
const accent = ACCENTS[shot.accent];
const isLiked = !!liked[id];
return (
<motion.div
key={id}
className="absolute origin-bottom cstk-card"
style={{
width: dims.w,
height: dims.h,
left: "50%",
marginLeft: -dims.w / 2,
bottom: dims.pad - 40,
zIndex: st.zIndex,
}}
initial={false}
animate={{ x: st.x, y: st.y, rotate: st.rotate, scale: st.scale }}
transition={
reduce
? { duration: 0 }
: {
type: "spring",
stiffness: 240,
damping: 24,
delay: expanded ? slot * 0.03 : 0,
}
}
>
<div className="relative h-full w-full overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-[0_10px_30px_-12px_rgba(24,24,27,0.45)] ring-1 ring-black/5 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-[0_16px_40px_-14px_rgba(0,0,0,0.8)] dark:ring-white/5">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={shot.img}
alt={`${shot.title} — photograph by ${shot.author}`}
loading="lazy"
draggable={false}
className="absolute inset-0 h-full w-full select-none object-cover"
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/80 via-black/25 to-transparent" />
<button
type="button"
onClick={() => bringToFront(id)}
className={`absolute inset-0 z-10 flex flex-col justify-end p-4 text-left outline-none transition-[box-shadow] focus-visible:ring-2 focus-visible:ring-inset ${accent.ring}`}
aria-label={`Bring “${shot.title}” to the front of the deck`}
>
<span className="pointer-events-none">
<span
className={`mb-2 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ${accent.chip}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${accent.dot}`} />
{shot.place}
</span>
<span
className={`block font-semibold leading-tight text-white ${
size === "lg" ? "text-lg" : "text-sm"
}`}
>
{shot.title}
</span>
<span className="mt-0.5 block text-xs text-zinc-300">
{shot.author}
</span>
</span>
</button>
<button
type="button"
onClick={() => toggleLike(id)}
aria-pressed={isLiked}
aria-label={
isLiked ? `Unlike ${shot.title}` : `Like ${shot.title}`
}
className={`absolute right-3 top-3 z-20 grid h-9 w-9 place-items-center rounded-full border backdrop-blur outline-none transition-all focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-rose-400 active:scale-90 ${
isLiked
? "border-rose-300/60 bg-rose-500/90 text-white dark:border-rose-400/50"
: "border-white/30 bg-black/25 text-white hover:bg-black/40"
}`}
>
<HeartIcon filled={isLiked} />
</button>
</div>
</motion.div>
);
})}
<span
aria-hidden="true"
className={`pointer-events-none absolute top-2 left-1/2 -ml-1 h-2 w-2 rounded-full ${topAccent.dot} cstk-pulse ${
expanded ? "opacity-0" : "opacity-100"
} transition-opacity`}
/>
</div>
<div className="mt-2 flex w-full max-w-[20rem] items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{label}
</p>
<p className="truncate text-xs text-zinc-500 dark:text-zinc-400">
{expanded ? `Now showing “${top.title}”` : hint}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={shuffle}
aria-label="Send the top photo to the back of the deck"
className="grid h-9 w-9 place-items-center rounded-xl border border-zinc-200 bg-white text-zinc-600 outline-none transition-colors hover:bg-zinc-50 hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-indigo-500 active:scale-95 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-white"
>
<ShuffleIcon />
</button>
<button
type="button"
onClick={() => setPinned((p) => !p)}
aria-pressed={pinned}
id={`${baseId}-toggle`}
className={`inline-flex h-9 items-center gap-1.5 rounded-xl border px-3 text-xs font-semibold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 active:scale-95 ${
pinned
? "border-indigo-500 bg-indigo-500 text-white hover:bg-indigo-600 dark:border-indigo-400 dark:bg-indigo-500"
: "border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
}`}
>
<LayersIcon open={pinned} />
{pinned ? "Collapse" : "Fan out"}
</button>
</div>
</div>
</div>
);
}
export default function CardStacked() {
return (
<section className="relative w-full overflow-hidden bg-zinc-50 px-6 py-20 sm:py-28 dark:bg-zinc-950">
<style>{`
@keyframes cstk-pulse {
0%, 100% { transform: scale(1); opacity: .9; }
50% { transform: scale(1.9); opacity: .25; }
}
.cstk-pulse { animation: cstk-pulse 2.4s ease-in-out infinite; }
.cstk-card { will-change: transform; }
@media (prefers-reduced-motion: reduce) {
.cstk-pulse { animation: none; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-0 opacity-[0.04] dark:opacity-[0.06]"
style={{
backgroundImage:
"radial-gradient(currentColor 1px, transparent 1px)",
backgroundSize: "22px 22px",
color: "currentColor",
}}
/>
<div className="relative z-10 mx-auto max-w-5xl">
<div className="mx-auto mb-16 max-w-2xl text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-3 py-1 text-xs font-medium text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Field Notebook · Vol. 07
</span>
<h2 className="mt-5 text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl dark:text-zinc-50">
A deck that fans on hover
</h2>
<p className="mx-auto mt-3 max-w-xl text-pretty text-base text-zinc-600 dark:text-zinc-400">
Hover or focus a stack to spread the cards. Pin it open, shuffle the
top shot to the back, or tap any card to pull it forward.
</p>
</div>
<div className="grid items-start gap-y-16 gap-x-8 sm:grid-cols-2">
<CardStack
shots={DECK_A}
size="lg"
label="Five continents, one lens"
hint="Hover to fan · tap a card to raise it"
/>
<CardStack
shots={DECK_B}
size="sm"
label="Compact set"
hint="Same deck, smaller footprint"
/>
</div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
3D Tilt Cards
OriginalA responsive grid of cards that tilt in 3D toward your cursor with a light glare and lifted depth layers, powered by Framer Motion springs.

Spotlight Follow Cards
OriginalFeature cards where a soft radial spotlight and a glowing border chase the cursor on hover, with a staggered scroll reveal and an animated shimmer line.

3D Flip Cards
OriginalPricing-style cards that flip in 3D on hover or tap to reveal details on the back, keyboard accessible with a staggered entrance animation.

Animated Gradient Border Cards
OriginalCards wrapped in a live conic gradient border that rotates and speeds up on hover, with a shimmer sweep and a scrolling tag marquee.

Glow Effect Card
primitivesA reusable glow effect card for React and Tailwind, with hover and motion.

Neon Gradient Card
gradient-card.tsxA reusable neon gradient card for React and Tailwind, with hover and motion.

Spotlight Magic Card
card.tsxA reusable spotlight magic card for React and Tailwind, with hover and motion.

Tilt Spotlight Card
primitivesA reusable tilt spotlight card for React and Tailwind, with hover and motion.

Profile Card
OriginalA user profile card with avatar, stats and a follow toggle.

Product Card
OriginalA product card with image, price and add-to-cart feedback.

Pricing Single Card
OriginalA single pricing plan card with feature list and CTA.

Stat Card
OriginalA metric card with value, trend arrow and a small sparkline.

