uselayouts
componentanimated-collectionsource: ok · hit
Animated Collection
A collection of items with smooth layout transitions and animations.
npx shadcn add "https://uselayouts.com/r/animated-collection.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Files1 · source cached 10/9/2026, 10:17:25 PM
registry/default/example/animated-collection.tsx
"use client";
import React, { useState } from "react";
import {
motion,
LayoutGroup,
AnimatePresence,
type Transition,
} from "motion/react";
import {
Playlist01Icon,
GridViewIcon,
Layers01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { cn } from "@/lib/utils";
interface CollectionItem {
id: string;
title: string;
subtitle: string;
idNumber: string;
image: string;
}
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",
},
{
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",
},
];
type ViewMode = "list" | "card" | "pack";
const snappySpring: Transition = {
type: "spring",
stiffness: 350,
damping: 30,
mass: 1,
};
const fadeMeta: Transition = {
duration: 0.16,
ease: [0.16, 1, 0.3, 1],
};
const PACK_POSE = [
{ rotate: -11, x: -22, y: 6 },
{ rotate: 9, x: 24, y: -8 },
] as const;
export default function LayoutSwitcher() {
const [view, setView] = useState<ViewMode>("list");
return (
<div className="mx-auto w-full max-w-xl p-4 font-sans antialiased [-webkit-font-smoothing:antialiased] selection:bg-foreground/10 md:p-8">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<div className="flex items-end justify-between gap-4">
<h2 className="text-xl font-medium tracking-tight text-balance text-foreground">
My Collection
</h2>
<span className="shrink-0 text-xs font-medium tabular-nums text-muted-foreground">
{ITEMS.length} items
</span>
</div>
<LayoutGroup id="ac-tabs">
<div
role="tablist"
aria-label="Collection layout"
className="flex w-fit rounded-full bg-muted/70 p-1 shadow-[0_0_0_1px_rgba(0,0,0,0.06)] dark:shadow-[0_0_0_1px_rgba(255,255,255,0.08)]"
>
<Tab
active={view === "list"}
onClick={() => setView("list")}
icon={Playlist01Icon}
label="List"
/>
<Tab
active={view === "card"}
onClick={() => setView("card")}
icon={GridViewIcon}
label="Cards"
/>
<Tab
active={view === "pack"}
onClick={() => setView("pack")}
icon={Layers01Icon}
label="Pack"
/>
</div>
</LayoutGroup>
</div>
<div className="h-px w-full bg-border" />
<div className="relative min-h-[360px]">
<LayoutGroup id="ac-items">
<motion.div
layout
initial={false}
transition={snappySpring}
className={cn(
"relative w-full",
view === "list" && "flex flex-col gap-3",
view === "card" && "grid grid-cols-2 gap-4",
view === "pack" &&
"absolute inset-x-0 top-0 bottom-16 flex items-center justify-center"
)}
>
{ITEMS.map((item, index) => (
<motion.div
key={item.id}
layout
initial={false}
transition={snappySpring}
className={cn(
"relative z-10 flex items-center",
view === "list" && "w-full gap-4",
view === "card" && "w-full flex-col items-start gap-3",
view === "pack" &&
"absolute size-52 items-center justify-center"
)}
style={{
zIndex: view === "pack" ? ITEMS.length - index : 1,
}}
animate={
view === "pack"
? PACK_POSE[index] ?? { rotate: 0, x: 0, y: 0 }
: { rotate: 0, x: 0, y: 0 }
}
>
<motion.div
layout
initial={false}
transition={snappySpring}
className={cn(
"relative shrink-0 overflow-hidden bg-muted outline outline-1 outline-black/10 dark:outline-white/10",
view === "list" && "size-16 rounded-2xl",
view === "card" &&
"aspect-square w-full rounded-[1.5rem] shadow-[0_1px_1px_rgba(0,0,0,0.04),0_8px_24px_rgba(0,0,0,0.06)] dark:shadow-[0_0_0_1px_rgba(255,255,255,0.08)]",
view === "pack" &&
"size-full rounded-[1.75rem] shadow-[0_1px_2px_rgba(0,0,0,0.08),0_18px_40px_rgba(0,0,0,0.18)] dark:shadow-[0_0_0_1px_rgba(255,255,255,0.1),0_18px_40px_rgba(0,0,0,0.45)]"
)}
>
<motion.img
layout
initial={false}
transition={snappySpring}
src={item.image}
alt={item.title}
width={400}
height={400}
className={cn(
"pointer-events-none m-0! block size-full object-cover p-0! select-none",
view === "list" && "rounded-2xl",
view === "card" && "rounded-[1.5rem]",
view === "pack" && "rounded-[1.75rem]"
)}
/>
</motion.div>
<AnimatePresence mode="popLayout" initial={false}>
{view !== "pack" && (
<motion.div
key={`${item.id}-info`}
layout
initial={{ opacity: 0, filter: "blur(4px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(4px)" }}
transition={fadeMeta}
className={cn(
"min-w-0 flex-1",
view === "card" && "w-full px-0.5"
)}
>
<div className="flex min-w-0 flex-col gap-0.5">
<h3 className="truncate text-[15px] font-medium leading-tight tracking-tight text-foreground">
{item.title}
</h3>
<p className="m-0 truncate text-xs text-muted-foreground">
{item.subtitle}
<span className="tabular-nums"> · {item.idNumber}</span>
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
</motion.div>
</LayoutGroup>
<AnimatePresence initial={false}>
{view === "pack" && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={fadeMeta}
className="absolute inset-x-0 bottom-4 flex flex-col items-center gap-1 text-center"
>
<p className="m-0 max-w-[28ch] text-sm font-medium tracking-tight text-balance text-foreground">
{ITEMS.map((item) => item.title).join(" · ")}
</p>
<p className="m-0 text-xs tabular-nums text-muted-foreground">
{ITEMS.length} pieces
</p>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
}
function Tab({
active,
onClick,
icon,
label,
}: {
active: boolean;
onClick: () => void;
icon: typeof Playlist01Icon;
label: string;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={cn(
"relative flex h-10 min-w-[5.75rem] cursor-pointer items-center justify-center gap-2 rounded-full px-4 text-sm font-medium outline-none transition-[color,background-color,box-shadow,transform] duration-150 ease-out",
"active:scale-[0.96] focus-visible:ring-2 focus-visible:ring-foreground/25",
active
? "text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
{active && (
<motion.div
layoutId="ac-active-tab"
className="pointer-events-none absolute inset-0 rounded-full bg-primary shadow-[0_1px_2px_rgba(0,0,0,0.12)]"
transition={snappySpring}
/>
)}
<span className="relative z-10 flex items-center gap-2">
<HugeiconsIcon icon={icon} size={15} strokeWidth={2} />
{label}
</span>
</button>
);
}