Animated Collection
"use client";
import {
motion,
LayoutGroup,
AnimatePresence,
type Transition,
} from "motion/react";
import {
Playlist01Icon,
GridViewIcon,
Layers01Icon,
StarIcon,
Ticket01Icon,
Camera01Icon,
BrushIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { cn } from "@/lib/utils";
import React, { useState } from "react";
interface CollectionItem {
id: string;
title: string;
subtitle: string;
idNumber: string;
image: string;
icon: any;
}
// Change Here
const ITEMS: CollectionItem[] = [
{
id: "1",
title: "Cinematic Horizons",
subtitle: "Photography",
idNumber: "209",
image:
"https://images.unsplash.com/photo-1506744038136-46273834b3fb?q=80&w=400&h=400&auto=format&fit=crop",
icon: Camera01Icon,
},
{
id: "2",
title: "Abstract Dreams",
subtitle: "Digital Art",
idNumber: "808",
image:
"https://images.unsplash.com/photo-1541701494587-cb58502866ab?q=80&w=400&h=400&auto=format&fit=crop",
icon: BrushIcon,
},
];
type ViewMode = "list" | "card" | "pack";
const snappySpring: Transition = {
type: "spring",
stiffness: 350,
damping: 30,
mass: 1,
};
const fastFade: Transition = {
duration: 0.1,
ease: "linear",
};
export default function LayoutSwitcher() {
const [view, setView] = useState<ViewMode>("list");
return (
<div className="w-full max-w-xl mx-auto p-4 md:p-8 font-sans selection:bg-primary/10">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-5">
<h2 className="text-xl font-medium text-foreground ">
My Collection
</h2>
<div className="flex p-1 bg-muted rounded-full w-fit border border-border">
<Tab
active={view === "list"}
onClick={() => setView("list")}
icon={Playlist01Icon}
label="List view"
/>
<Tab
active={view === "card"}
onClick={() => setView("card")}
icon={GridViewIcon}
label="Card view"
/>
<Tab
active={view === "pack"}
onClick={() => setView("pack")}
icon={Layers01Icon}
label="Pack view"
/>
</div>
</div>
<div className="h-px bg-border w-full" />
{/* Content Section */}
<div className="relative min-h-[350px] flex flex-col items-center">
<LayoutGroup>
<motion.div
layout
transition={snappySpring}
className={cn(
"w-full relative",
view === "list" && "flex flex-col gap-4",
view === "card" && "grid grid-cols-2 gap-4",
view === "pack" && "h-64 flex items-center justify-center mt-8"
)}
>
{ITEMS.map((item, index) => (
<motion.div
key={item.id}
layout
transition={snappySpring}
className={cn(
"relative flex items-center z-10",
view === "list" && "flex-row gap-4 w-full",
view === "card" && "flex-col gap-3 w-full items-start",
view === "pack" &&
"absolute w-56 h-56 items-center justify-center"
)}
style={{
zIndex: view === "pack" ? ITEMS.length - index : 1,
}}
animate={
view === "pack"
? {
rotate: index === 0 ? -12 : 6,
x: index === 0 ? -25 : 25,
y: index === 0 ? -5 : 5,
}
: {
rotate: 0,
x: 0,
y: 0,
}
}
>
<motion.div
layout
transition={snappySpring}
className={cn(
"relative overflow-hidden shrink-0 bg-background",
view === "list" &&
"w-16 h-16 rounded-2xl border border-border/50 ",
view === "card" &&
"w-full aspect-square rounded-[1.8rem] border border-border/50 shadow-sm",
view === "pack" &&
"w-full h-full rounded-[2rem] border border-border/50 shadow-xl"
)}
>
<motion.img
layout
transition={snappySpring}
src={item.image}
alt={item.title}
className={cn(
"w-full h-full object-cover m-0! p-0! block",
view === "list" && "rounded-2xl",
view === "card" && "rounded-[1.8rem]",
view === "pack" && "rounded-[2rem]"
)}
/>
</motion.div>
<AnimatePresence mode="popLayout" initial={false}>
{view !== "pack" && (
<motion.div
key={`${item.id}-info`}
layout
initial={{
opacity: 0,
scale: 0.9,
filter: "blur(4px)",
}}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.9, filter: "blur(4px)" }}
transition={fastFade}
className={cn(
"flex flex-1 justify-between items-center min-w-0",
view === "card" ? "w-full px-1" : "px-0"
)}
>
<div className="flex flex-col gap-0.5 min-w-0">
<motion.h3
layout
className="font-medium text-[15px] text-foreground leading-tight truncate"
>
{item.title}
</motion.h3>
<motion.div
layout
className="text-muted-foreground font-medium text-xs flex items-center gap-1.5"
>
<HugeiconsIcon
icon={item.icon}
size={12}
className="text-primary/70"
/>
<span className="truncate">{item.subtitle}</span>
</motion.div>
</div>
<motion.div
layout
className="flex items-center gap-1 px-2 py-1 rounded-full bg-primary/5 text-primary text-[10px] font-bold shrink-0 ml-2"
>
<HugeiconsIcon
icon={StarIcon}
size={10}
className="text-yellow-500 fill-yellow-500"
/>
<span>#{item.idNumber}</span>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{view === "list" && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="absolute -bottom-2 left-18 right-0 h-px bg-border/40"
/>
)}
</motion.div>
))}
</motion.div>
<AnimatePresence>
{view === "pack" && (
<motion.div
layout
initial={{ opacity: 0, y: 10, filter: "blur(5px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: 5, filter: "blur(5px)" }}
transition={{ duration: 0.3, delay: 0.1 }}
className="mt-16 text-center space-y-3 px-4 relative z-0"
>
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 text-primary text-[11px] font-bold uppercase tracking-wide">
<HugeiconsIcon icon={Ticket01Icon} size={12} />
<span>Bundle unlocked</span>
</div>
</motion.div>
)}
</AnimatePresence>
</LayoutGroup>
</div>
</div>
</div>
);
}
function Tab({
active,
onClick,
icon,
label,
}: {
active: boolean;
onClick: () => void;
icon: any;
label: string;
}) {
return (
<button
onClick={onClick}
className={cn(
"relative flex items-center gap-2 px-4 py-2 text-sm font-normal uppercase transition-all rounded-full outline-none",
active
? "text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
)}
>
{active && (
<motion.div
layoutId="active-tab"
className="absolute inset-0 bg-primary rounded-full shadow-md"
transition={snappySpring}
/>
)}
<span className="relative z-10 flex items-center gap-2">
<HugeiconsIcon
icon={icon}
size={16}
className={cn(
"transition-transform duration-300",
active && "scale-110"
)}
/>
{label}
</span>
</button>
);
}
Installation
npx shadcn@latest add "https://uselayouts.com/r/animated-collection.json"Install dependencies
npm install motion @hugeicons/react @hugeicons/core-free-iconsCopy the code
Copy the code from the Code tab above into components/layout-switcher.tsx.
Update imports
Update the imports to match your project structure.
Usage
Customizing Content
You can easily swap out the items by modifying the ITEMS array at the top of the component file:
// Change Here
const ITEMS = [
{
id: "1",
title: "Cinematic Horizons",
subtitle: "Photography",
idNumber: "209",
image: "https://...",
icon: Camera01Icon,
},
// ... add more items
];import LayoutSwitcher from "@/components/layout-switcher";
export default function Page() {
return <LayoutSwitcher />;
}Features
- Morphing Layout: Seamlessly transitions between List, Card, and Pack views.
- Shared Element Transitions: Elements smoothly resize and reposition during layout changes.
- Pack View: A unique "stack" or "pack" view simulation.
- View Toggle: Integrated toggle switch for different layout modes.