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>
),
},
];