Docs

Card Stack

Cards stack on top of each other after some interval. Perfect for showing testimonials.

Installation

Install dependencies

npm i motion clsx tailwind-merge

Add util file

lib/utils.ts

import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
 
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
 

Run the following command

It will create a new file card-stack.tsx inside the components/mage-ui/card directory.

mkdir -p components/mage-ui/card && touch components/mage-ui/card/card-stack.tsx

Paste the code

Open the newly created file and paste the following code:

"use client";
import { useEffect, useState } from "react";
import { motion } from "framer-motion"; // Changed from "motion/react" to "framer-motion"
import { cn } from "@/lib/utils";
 
// Use NodeJS.Timeout instead of any
let interval: NodeJS.Timeout | null = null;
 
type Card = {
  id: number;
  name: string;
  designation: string;
  content: React.ReactNode;
};
 
export const CardStack = ({
  items,
  offset = 10,
  scaleFactor = 0.06,
}: {
  items: Card[];
  offset?: number;
  scaleFactor?: number;
}) => {
  const [cards, setCards] = useState<Card[]>(items || []); // Add a fallback empty array
 
  useEffect(() => {
    // Only start the interval if there are cards
    if (cards.length > 0) {
      startFlipping();
      return () => {
        if (interval) clearInterval(interval);
      };
    }
  }, [cards.length]); // Add cards.length as a dependency
 
  const startFlipping = () => {
    interval = setInterval(() => {
      setCards((prevCards: Card[]) => {
        if (prevCards.length === 0) return prevCards;
        const newArray = [...prevCards];
        newArray.unshift(newArray.pop()!);
        return newArray;
      });
    }, 5000);
  };
 
  // Return early if there are no cards
  if (!cards || cards.length === 0) {
    return <div className="relative h-60 w-60 md:h-60 md:w-96">No cards to display</div>;
  }
 
  return (
    <div className="relative h-60 w-60 md:h-60 md:w-96">
      {cards.map((card, index) => (
        <motion.div
          key={card.id}
          className="absolute dark:bg-black bg-white h-60 w-60 md:h-60 md:w-96 rounded-3xl p-4 shadow-xl border border-neutral-200 dark:border-white/[0.1] shadow-black/[0.1] dark:shadow-white/[0.05] flex flex-col justify-between"
          style={{ transformOrigin: "top center" }}
          animate={{
            top: index * -offset,
            scale: 1 - index * scaleFactor,
            zIndex: cards.length - index,
          }}
        >
          <div className="font-normal text-neutral-700 dark:text-neutral-200">
            {card.content}
          </div>
          <div>
            <p className="text-neutral-500 font-medium dark:text-white">
              {card.name}
            </p>
            <p className="text-neutral-400 font-normal dark:text-neutral-200">
              {card.designation}
            </p>
          </div>
        </motion.div>
      ))}
    </div>
  );
};
 
export const CardStackDemo = () => {
  return (
    <div className="h-[40rem] flex items-center justify-center w-full">
      <CardStack items={CARDS} />
    </div>
  );
};
 
export const Highlight = ({
  children,
  className,
}: {
  children: React.ReactNode;
  className?: string;
}) => {
  return (
    <span
      className={cn(
        "font-bold bg-emerald-100 text-emerald-700 dark:bg-emerald-700/[0.2] dark:text-emerald-500 px-1 py-0.5",
        className
      )}
    >
      {children}
    </span>
  );
};
 
export const CARDS = [
  {
    id: 0,
    name: "ANUXR4G",
    designation: "Senior Software Engineer",
    content: (
      <p>
        These cards are amazing, <Highlight>I want to use them</Highlight> in
        my project. Framer motion is a godsend ngl tbh fam 🙏
      </p>
    ),
  },
  {
    id: 1,
    name: "Ashok",
    designation: "Senior Shitposter",
    content: (
      <p>
        I dont like this Twitter thing, {" "}
        <Highlight>deleting it right away</Highlight> because yolo. Instead, I
        would like to call it <Highlight>X.com</Highlight> so that it can
        easily be confused with adult sites.
      </p>
    ),
  },
  {
    id: 2,
    name: "Nikita",
    designation: "Manager Project Mayhem",
    content: (
      <p>
        The first rule of <Highlight>Fight Club</Highlight> is that you do not
        talk about fight club. The second rule of <Highlight>Fight club</Highlight> is that you DO NOT TALK about fight club.
      </p>
    ),
  },
];