Back to library
carousels
List Stack
Stacked activity cards in 3D perspective that fan out into a list when expanded. Spring-animated with Framer Motion.
Inspired by Watermelon UI · View source
Preview
Camping
Yosemite Park
Boating
Lake Tahoe Park
Barbecue
Greenfield Shores
Show
Copy for AI
Copy for Lovable is the fastest way to move this component into another Lovable project — it copies the full prompt with dependencies, files, and a usage example. Copy all files gives you the raw source if you want to paste it manually.
Installation
npx shadcn@latest add https://registry.watermelon.sh/r/list-stack.jsonPaste into a terminal — or paste into a Lovable chat and I'll run it for you.
Source files
components/ui/list-stack.tsx
"use client";
import { useState, type FC } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { FaFireFlameCurved, FaSailboat } from "react-icons/fa6";
import { LuTent } from "react-icons/lu";
import { type IconType } from "react-icons";
interface ListItem {
id: string;
title: string;
location: string;
date: string;
icon: IconType;
}
interface ListStackProps {
items?: ListItem[];
}
const ITEMS: ListItem[] = [
{
id: "1",
title: "Camping",
location: "Yosemite Park",
date: "5 August",
icon: LuTent,
},
{
id: "2",
title: "Boating",
location: "Lake Tahoe Park",
date: "2 August",
icon: FaSailboat,
},
{
id: "3",
title: "Barbecue",
location: "Greenfield Shores",
date: "28 July",
icon: FaFireFlameCurved,
},
];
const CARD_HEIGHT = 60;
const GAP = 8;
const COLLAPSED_OFFSET = -7;
export const ListStack: FC<ListStackProps> = ({ items = ITEMS }) => {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="h- w-full font-sans">
<div className="flex h-[400px] w-full flex-col items-center justify-center">
<div className="relative flex h-full w-full flex-col items-center justify-center perspective-[1000px]">
{items.map((item, i) => {
return (
<motion.div
className="bg-opacity-80 absolute flex w-[270px] items-center rounded-2xl bg-white px-3 py-3 shadow-[0px_0px_2px_rgba(0,0,0,0.1),0px_1px_4px_rgba(0,0,0,0.15)] backdrop-blur-2xl dark:border-gray-100 dark:bg-zinc-800"
key={i}
animate={isExpanded ? "expanded" : "collapsed"}
style={{
height: CARD_HEIGHT,
}}
variants={{
expanded: {
y: (items.length - 1 - i) * (CARD_HEIGHT + GAP),
z: 10,
},
collapsed: {
y: i * COLLAPSED_OFFSET,
z: i * 60,
},
}}
transition={{
type: "spring",
stiffness: 200,
damping: 23,
}}
>
<div className="flex w-full items-center font-sans">
<div
key={i}
className="flex w-full items-center gap-x-2 text-neutral-900"
>
<div className="flex size-10 items-center justify-center rounded-md bg-black text-zinc-100 dark:bg-white dark:text-zinc-700">
<item.icon size={24} />
</div>
<div className="leading-tighter flex-1">
<h3 className="text-sm text-zinc-900 dark:text-zinc-100">
{item.title}
</h3>
<p className="text-xs text-zinc-500 dark:text-zinc-400">
{item.location}
</p>
</div>
<span className="leading-tighter mb-[2px] ml-5 self-end text-end text-xs text-zinc-500 dark:text-zinc-400">
{item.date}
</span>
</div>
</div>
</motion.div>
);
})}
<motion.div
className="absolute cursor-pointer rounded-2xl border border-gray-100 bg-white px-4 py-2 text-neutral-900 shadow-sm"
animate={{
y: isExpanded
? (items.length - 1) * (CARD_HEIGHT + GAP) + CARD_HEIGHT + GAP
: CARD_HEIGHT + GAP,
z: isExpanded ? 0 : 40,
}}
transition={{
type: "spring",
stiffness: 200,
damping: 25,
}}
layout
onClick={() => {
setIsExpanded((prev) => !prev);
}}
>
<AnimatePresence mode="popLayout">
<motion.span
layout
key={isExpanded ? "hide" : "show"}
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
transition={{
duration: 0.2,
}}
>
{" "}
{isExpanded ? "Hide " : "Show "}
</motion.span>
</AnimatePresence>
</motion.div>
</div>
</div>
</div>
);
};
Dependencies
framer-motionreact-icons