Perspective Card Stack

A 3D card stack that fans out on hover. Displays up to 3 cards that stack vertically when idle and spread into a fanned perspective layout when hovering. Uses CSS transforms and Next.js Image for optimized rendering.

React
Tailwind CSS
Cards
3D
Perspective
Hover
Card 1
Card 2
Card 3

Using CLI

npx dimaac add PerspectiveCardStack

Manual Installation

npm install react next

lib/utils.ts

import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
} 

components/ui/cards/PerspectiveCardStack.tsx

'use client';

import React from 'react';
import Image from 'next/image';
import { cn } from '@/lib/utils';

export interface PerspectiveCard {
  image: string;
  alt?: string;
}

interface PerspectiveCardStackProps {
  cards: PerspectiveCard[];
  className?: string;
  cardWidth?: number;
  cardHeight?: number;
}


const cardConfigs = [
  {
    zIndex: 3,
    stacked: 'translate(-50%, -50%)',
    fanned: 'translate(-72%, -50%) scale(0.8) rotateY(-48deg)',
  },
  {
    zIndex: 2,
    stacked: 'translate(-50%, -70%) scale(0.9)',
    fanned: 'translate(-52%, -50%) scale(0.7) rotateY(-36deg)',
  },
  {
    zIndex: 1,
    stacked: 'translate(-50%, -90%) scale(0.8)',
    fanned: 'translate(-32%, -50%) scale(0.6) rotateY(-24deg)',
  },
];

const PerspectiveCardStack: React.FC<PerspectiveCardStackProps> = ({
  cards,
  className,
  cardWidth = 360,
  cardHeight = 280,
}) => {
  const visibleCards = cards.slice(0, 3);

  return (
    <>
      <style>{`
        .perspective-stack {
          perspective: 500px;
          transform: scale(1);
          transition: transform 0.2s ease;
        }
        .perspective-stack:hover {
          transform: scale(1);
        }
        .perspective-card {
          position: absolute;
          top: 50%;
          left: 50%;
          cursor: pointer;
          transition: transform 0.4s ease, margin-top 0.4s ease;
        }
        .perspective-card:hover {
          margin-top: -40px;
        }
        ${cardConfigs
          .map(
            (config, i) => `
          .perspective-card-${i} {
            z-index: ${config.zIndex};
            transform: ${config.stacked};
          }
          .perspective-stack:hover .perspective-card-${i} {
            transform: ${config.fanned};
          }
        `
          )
          .join('')}
      `}</style>

      <div
        className={cn(
          'perspective-stack relative flex items-center justify-center w-full max-w-[680px] aspect-[4/3] min-h-[320px]',
          className
        )}
      >
        {visibleCards.map((card, index) => (
          <div
            key={index}
            className={`perspective-card perspective-card-${index} relative overflow-hidden rounded-[10px]`}
            style={{ width: cardWidth, height: cardHeight }}
          >
            <Image
              src={card.image}
              alt={card.alt ?? `Card ${index + 1}`}
              fill
              className="object-cover object-top"
              sizes={`(max-width: 640px) ${cardWidth}px, (max-width: 1024px) ${cardWidth}px, ${cardWidth}px`}
            />
          </div>
        ))}
      </div>
    </>
  );
};

export default PerspectiveCardStack;