stacked-cards

Stacked Cards Pattern

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "stacked-cards" with this command: npx skills add ainergiz/design-inspirations/ainergiz-design-inspirations-stacked-cards

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; // Horizontal offset between cards const cardSize = 130; // Card width 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 (
      &#x3C;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)}
      >
        &#x3C;div className="relative w-[130px] h-[130px] rounded-2xl overflow-hidden shadow-xl">
          &#x3C;Image
            src={card.imageUrl}
            alt={card.title}
            fill
            className="object-cover"
          />
        &#x3C;/div>
      &#x3C;/div>
    );
  })}

  {/* Tooltip rendered OUTSIDE the stack to escape z-index stacking context */}
  {hoveredIndex !== null &#x26;&#x26; cards[hoveredIndex] &#x26;&#x26; (() => {
    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 (
      &#x3C;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%)`,
        }}
      >
        &#x3C;p className="text-sm font-semibold text-zinc-900">{card.title}&#x3C;/p>
      &#x3C;/div>
    );
  })()}
&#x3C;/div>

); }

Key Elements

  1. Reverse Render Order

// CORRECT - First card renders LAST in DOM, appears on top {[...cards].reverse().map((card, renderIndex, arr) => { const cardIndex = arr.length - 1 - renderIndex; // ... })}

// WRONG - First card renders first, appears BEHIND others {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.

  1. Z-Index Without Hover Change

// CORRECT - Z-index based only on position, unchanged on hover const zIndex = arr.length - cardIndex;

// WRONG - Changing z-index on hover breaks the cascade 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.

  1. Hover Lift Only (No Scale)

// CORRECT - Only translateY, preserves cascade illusion const translateY = hoveredIndex === cardIndex ? -20 : 0;

// AVOID - Scale makes card "pop out" visually const scale = hoveredIndex === cardIndex ? 1.05 : 1;

  1. Tooltip Outside Stacking Context

// WRONG - Tooltip inside card div is trapped by parent's z-index <div style={{ zIndex: 3 }}> <div className="card">...</div> {hovered && <div className="tooltip z-50">...</div>} {/* z-50 doesn't help! */} </div>

// CORRECT - Tooltip as sibling, outside all card divs {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.

  1. Centering Formula

const spacing = 40; // Gap between card left edges const cardSize = 130; // Card width const stackWidth = (cards.length - 1) * spacing + cardSize;

// For each card, calculate horizontal offset from center const translateX = -stackWidth / 2 + cardIndex * spacing + cardSize / 2;

Then use left-1/2 with translateX(calc(-50% + ${translateX}px)) for centering.

  1. Clear Hover State on Navigation

const handleSelectCard = (index: number) => { setHoveredIndex(null); // Clear before view change setSelectedCard(index); setView("detail"); };

const handleBack = () => { setHoveredIndex(null); // Clear when returning 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:

// Container height: 180px, card height: 130px, lift: 20px // Card top when lifted = center - halfCard - lift = 90 - 65 - 20 = 5px

// Tooltip should be above this with gap // Position: center (50%) - halfCard (65px) - lift (20px) - gap (10px) = 50% - 95px const tooltipTop = 'calc(50% - 95px)';

// translateY(-100%) moves tooltip up by its own height 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

  • Render order reversed with [...array].reverse().map()

  • Card index calculated from render index: arr.length - 1 - renderIndex

  • Z-index decreases with card index: arr.length - cardIndex

  • Z-index does NOT change on hover

  • Hover only applies translateY , no scale

  • Tooltip rendered OUTSIDE the card loop as sibling

  • Tooltip has pointer-events-none to avoid hover interference

  • Hover state cleared on view transitions

  • Container has relative with fixed height

  • Cards use absolute left-1/2 with calculated translateX

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

image-carousel

No summary provided by upstream source.

Repository SourceNeeds Review
General

create-new-design

No summary provided by upstream source.

Repository SourceNeeds Review
General

expandable-card

No summary provided by upstream source.

Repository SourceNeeds Review
General

nested-card

No summary provided by upstream source.

Repository SourceNeeds Review