UI Elements

Avatar List

A collapsible people list that morphs between an overlapping stacked deck and an expanded, fully readable list.

avatar-list.tsx
"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;