| name | stacked-cards |
| description | Creates horizontally fanned/cascading card stacks with proper z-index ordering and hover lift animations. Use when building album browsers, card fans, stacked previews, or any overlapping card collection. |
Stacked Cards Pattern
Build horizontally cascading card stacks where cards overlap in order, with hover animations that lift cards in place without breaking the cascade.
Why This Pattern?
Stacked cards have three common bugs:
- Wrong stacking order - Later cards in the array appear on top
- Hover breaks cascade - Changing z-index on hover disrupts the visual order
- Tooltip trapped in stacking context - Tooltips inside cards can't escape their parent's z-index
This pattern solves all three.
Core Concept
First card (front) Last card (back)
↓ ↓
┌─────┐
│ │┌─────┐
│ 1 ││ │┌─────┐
│ ││ 2 ││ │
└─────┘│ ││ 3 │
└─────┘│ │
└─────┘
- First item in array = front (highest z-index)
- Each subsequent item = behind and offset right
- Hover lifts card UP without changing z-index
Core Implementation
"use client";
import { useState } from "react";
import Image from "next/image";
interface Card {
id: string;
imageUrl: string;
title: string;
}
function StackedCards({ cards }: { cards: Card[] }) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const spacing = 40;
const cardSize = 130;
const stackWidth = (cards.length - 1) * spacing + cardSize;
return (
<div className="relative h-[180px] w-full flex items-center justify-center">
{/* CRITICAL: Reverse render order so first card renders LAST in DOM (appears on top) */}
{[...cards].reverse().map((card, renderIndex, arr) => {
// Convert render index back to actual card index
const cardIndex = arr.length - 1 - renderIndex;
// Center the stack horizontally
const translateX = -stackWidth / 2 + cardIndex * spacing + cardSize / 2;
// Z-index: first card (index 0) has HIGHEST z-index
const zIndex = arr.length - cardIndex;
// Hover: lift UP only, do NOT change z-index
const translateY = hoveredIndex === cardIndex ? -20 : 0;
return (
<div
key={card.id}
className="absolute left-1/2 cursor-pointer transition-all duration-300 ease-out"
style={{
transform: `translateX(calc(-50% + ${translateX}px)) translateY(${translateY}px)`,
zIndex,
}}
onMouseEnter={() => setHoveredIndex(cardIndex)}
onMouseLeave={() => setHoveredIndex(null)}
>
<div className="relative w-[130px] h-[130px] rounded-2xl overflow-hidden shadow-xl">
<Image
src={card.imageUrl}
alt={card.title}
fill
className="object-cover"
/>
</div>
</div>
);
})}
{/* Tooltip rendered OUTSIDE the stack to escape z-index stacking context */}
{hoveredIndex !== null && cards[hoveredIndex] && (() => {
const translateX = -stackWidth / 2 + hoveredIndex * spacing + cardSize / 2;
const card = cards[hoveredIndex];
// Position: center (50%) - half card - lift - gap
const tooltipTop = 'calc(50% - 95px)';
return (
<div
className="absolute px-3 py-2 rounded-xl whitespace-nowrap shadow-lg z-50 pointer-events-none bg-white/95 backdrop-blur-md border border-zinc-200/80"
style={{
left: '50%',
top: tooltipTop,
transform: `translateX(calc(-50% + ${translateX}px)) translateY(-100%)`,
}}
>
<p className="text-sm font-semibold text-zinc-900">{card.title}</p>
</div>
);
})()}
</div>
);
}
Key Elements
1. Reverse Render Order
{[...cards].reverse().map((card, renderIndex, arr) => {
const cardIndex = arr.length - 1 - renderIndex;
})}
{cards.map((card, index) => {
})}
Why? DOM order determines stacking when z-index values are the same within a parent. By rendering in reverse, the first logical card is the last DOM element, appearing on top.
2. Z-Index Without Hover Change
const zIndex = arr.length - cardIndex;
const zIndex = hoveredIndex === cardIndex ? 20 : arr.length - cardIndex;
Why? When you change z-index on hover, the card jumps to the front, breaking the visual illusion of a physical stack. Real cards lift UP in place while staying behind cards in front.
3. Hover Lift Only (No Scale)
const translateY = hoveredIndex === cardIndex ? -20 : 0;
const scale = hoveredIndex === cardIndex ? 1.05 : 1;
4. Tooltip Outside Stacking Context
<div style={{ zIndex: 3 }}>
<div className="card">...</div>
{hovered && <div className="tooltip z-50">...</div>} {}
</div>
{cards.map(...)}
{hoveredIndex !== null && (
<div className="absolute z-50" style={{ /* calculated position */ }}>
Tooltip content
</div>
)}
Why? A child element cannot escape its parent's stacking context. Tooltips inside cards with lower z-index will be covered by sibling cards with higher z-index.
5. Centering Formula
const spacing = 40;
const cardSize = 130;
const stackWidth = (cards.length - 1) * spacing + cardSize;
const translateX = -stackWidth / 2 + cardIndex * spacing + cardSize / 2;
Then use left-1/2 with translateX(calc(-50% + ${translateX}px)) for centering.
6. Clear Hover State on Navigation
const handleSelectCard = (index: number) => {
setHoveredIndex(null);
setSelectedCard(index);
setView("detail");
};
const handleBack = () => {
setHoveredIndex(null);
setView("list");
};
Why? Without clearing, the previously hovered card stays elevated when returning to the list view.
Tooltip Position Calculation
Position the tooltip above the lifted card:
const tooltipTop = 'calc(50% - 95px)';
style={{
top: tooltipTop,
transform: `translateX(...) translateY(-100%)`,
}}
Sizing Variations
| Context | Card Size | Spacing | Lift | Container Height |
|---|
| Preview | 80px | 28px | -12px | 110px |
| Full | 130px | 40px | -20px | 180px |
Light/Dark Variants
| Element | Light Mode | Dark Mode |
|---|
| Card shadow | shadow-zinc-400/50 | shadow-black/70 |
| Tooltip bg | bg-white/95 | bg-zinc-800/95 |
| Tooltip border | border-zinc-200/80 | border-zinc-700/80 |
| Tooltip text | text-zinc-900 | text-zinc-100 |
Checklist