A collapsible people list that morphs between an overlapping stacked deck and an expanded, fully readable list.
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ChevronRight, MessageSquare, PlusCircle, X } from "lucide-react";
import { AnimatePresence, motion as m, Variants } from "motion/react";
import { useState } from "react";
type User = {
id: string;
name: string;
email: string;
avatar: string;
add?: boolean;
};
type UserWithAdd = User | { add: boolean };
const avatar = (n: number) => `https://i.pravatar.cc/150?img=${n}`;
const defaultUser: UserWithAdd[] = [
{ id: "1", name: "John Doe", email: "john.doe@example.com", avatar: avatar(12) },
{ id: "2", name: "Jane Doe", email: "jane.doe@example.com", avatar: avatar(5) },
{ add: true },
];
// People pulled in, one at a time, when "Add User" is clicked.
const POOL = [
{ name: "Alex Rivera", email: "alex.rivera@example.com", avatar: avatar(15) },
{ name: "Mia Chen", email: "mia.chen@example.com", avatar: avatar(47) },
{ name: "Sam Carter", email: "sam.carter@example.com", avatar: avatar(33) },
{ name: "Leo Park", email: "leo.park@example.com", avatar: avatar(68) },
{ name: "Nora Blake", email: "nora.blake@example.com", avatar: avatar(24) },
{ name: "Owen Diaz", email: "owen.diaz@example.com", avatar: avatar(51) },
];
const textVariant: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
const spring = { type: "spring", stiffness: 400, damping: 34, mass: 1 } as const;
const UserStacked = () => {
const [users, setUsers] = useState<UserWithAdd[]>(defaultUser);
const [active, setActive] = useState(false);
const realUsers = users.filter((user): user is User => !user.add);
const addUser = () => {
setUsers((prev) => {
const real = prev.filter((u): u is User => !u.add);
const next = POOL[real.length % POOL.length];
const newUser: User = {
id: `u-${real.length + 1}-${next.name}`,
name: next.name,
email: next.email,
avatar: next.avatar,
};
return [...real, newUser, { add: true }];
});
setActive(true);
};
const removeUser = (id: string) => {
setUsers((prev) => prev.filter((u) => !("id" in u) || u.id !== id));
};
return (
<div className="full flex justify-center p-10 select-none">
<div className="flex w-full max-w-md flex-col gap-4 text-muted-foreground">
<button
type="button"
className="flex items-center justify-between rounded-lg p-2 transition-colors hover:bg-muted/40"
onClick={() => setActive((prev) => !prev)}
>
<span className="flex items-center gap-3">
{/* Overlapping avatar group */}
<span className="flex -space-x-2">
<AnimatePresence initial={false} mode="popLayout">
{realUsers.slice(0, 4).map((user) => (
<m.img
key={user.id}
layout
initial={{ opacity: 0, scale: 0.4 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.4 }}
transition={spring}
src={user.avatar}
alt=""
draggable={false}
className="size-6 shrink-0 rounded-full object-cover ring-2 ring-background outline outline-1 -outline-offset-1 outline-white/10"
/>
))}
</AnimatePresence>
{realUsers.length === 0 && (
<span className="size-6 rounded-full bg-muted ring-2 ring-background" />
)}
</span>
<span className="font-medium text-foreground">People</span>
<span className="center size-5 rounded-full bg-muted text-xs tabular-nums">
{realUsers.length}
</span>
</span>
<span className="flex items-center gap-2">
<AnimatePresence mode="popLayout" initial={false}>
{active ? (
<m.span variants={textVariant} initial="initial" animate="animate" exit="exit" key="open">
Hide
</m.span>
) : (
<m.span variants={textVariant} initial="initial" animate="animate" exit="exit" key="closed">
Show All
</m.span>
)}
</AnimatePresence>
<ChevronRight
className={cn("size-4 transition-transform duration-300", {
"rotate-90": active,
})}
/>
</span>
</button>
{active ? (
<UnStack users={users} onAdd={addUser} onRemove={removeUser} />
) : (
<Stack users={users} onAdd={addUser} onExpand={() => setActive(true)} />
)}
</div>
</div>
);
};
const UnStack = ({
users,
onAdd,
onRemove,
}: {
users: UserWithAdd[];
onAdd: () => void;
onRemove: (id: string) => void;
}) => {
return (
<div className="relative flex flex-col gap-2">
<AnimatePresence initial={false} mode="popLayout">
{users.map((user, index) => (
<UserCard
key={"id" in user ? user.id : "add"}
user={user}
index={index}
stack={false}
length={users.length}
onAdd={onAdd}
onRemove={onRemove}
/>
))}
</AnimatePresence>
</div>
);
};
const UserCard = ({
user,
stack,
index,
length,
onAdd,
onRemove,
}: {
user: UserWithAdd;
stack?: boolean;
index: number;
length: number;
onAdd: () => void;
onRemove: (id: string) => void;
}) => {
const isRealUser = "id" in user;
const isFront = index === 0;
// Sonner-style deck: the front card rests at the bottom and the rest peek
// upward behind it. Only two faint peeks, no matter how many users.
const depth = Math.min(index, 2);
const stackOpacity = [1, 0.5, 0.22][depth];
const stackScale = 1 - depth * 0.06;
return (
<m.div
layoutId={isRealUser ? `user-${user.id}` : "add-user"}
initial={{ opacity: 0, y: 16 }}
animate={{
opacity: stack ? stackOpacity : 1,
y: 0,
scale: stack ? stackScale : 1,
}}
exit={{
opacity: 0,
scale: 0.85,
transition: { duration: 0.18, ease: [0.4, 0, 1, 1] },
}}
style={{
bottom: stack ? depth * 12 : undefined,
zIndex: stack ? length - index : 0,
transformOrigin: stack ? "bottom center" : "center",
}}
transition={spring}
className={cn(
"flex items-center justify-between gap-2 rounded-lg border bg-background p-2 shadow-sm",
{
"absolute inset-x-0 mx-auto": stack,
"shadow-lg shadow-black/20": stack && isFront,
"border-border/50": stack && !isFront,
"bg-muted": user.add,
},
)}
>
{isRealUser ? (
// Background cards in the deck render as empty chrome so nothing leaks.
stack && !isFront ? (
<div className="h-6 w-full" />
) : (
<>
<div className="flex min-w-0 items-center gap-2">
<img
src={user.avatar}
alt=""
className="size-6 shrink-0 rounded-full object-cover outline outline-1 -outline-offset-1 outline-white/10"
draggable={false}
/>
<span className="truncate text-foreground">{user.name}</span>
</div>
{!stack && (
<div className="flex shrink-0 items-center gap-0.5">
<Button size="icon" variant="ghost" className="size-7 text-muted-foreground hover:text-foreground">
<MessageSquare className="!size-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
aria-label={`Remove ${user.name}`}
onClick={() => onRemove(user.id)}
className="size-7 text-muted-foreground hover:bg-red-500/10 hover:text-red-400"
>
<X className="!size-3.5" />
</Button>
</div>
)}
</>
)
) : stack ? (
<div className="h-6 w-full" />
) : (
<button
type="button"
onClick={onAdd}
className="flex w-full items-center justify-center gap-2 rounded-md bg-muted py-1 transition-colors hover:text-foreground"
>
<PlusCircle className="size-4" />
<span>Add User</span>
</button>
)}
</m.div>
);
};
const Stack = ({
users,
onAdd,
onExpand,
}: {
users: UserWithAdd[];
onAdd: () => void;
onExpand: () => void;
}) => {
return (
<div
role="button"
tabIndex={0}
onClick={onExpand}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onExpand();
}
}}
aria-label="Expand people list"
className="relative h-20 cursor-pointer"
>
{users.map((user, index) => (
<UserCard
key={"id" in user ? user.id : "add"}
user={user}
stack={true}
index={index}
length={users.length}
onAdd={onAdd}
onRemove={() => {}}
/>
))}
{/* Melt the upward back edges into the page so no hard borders peek out. */}
<div className="pointer-events-none absolute inset-x-0 top-0 z-[60] h-9 bg-gradient-to-b from-background via-background/80 to-transparent" />
</div>
);
};
export default UserStacked;