Web InnoventixFreeCode

Stacked Count Avatar

Original · free

avatar stack with +N count

byWeb InnoventixReact + Tailwind
avstackedcountavatars
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/av-stacked-count.json
av-stacked-count.tsx
"use client";

import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import {
  AnimatePresence,
  motion,
  useReducedMotion,
  type Variants,
} from "motion/react";

type Presence = "online" | "idle" | "offline";
type Filter = "all" | Presence;

interface Member {
  id: string;
  name: string;
  role: string;
  grad: string;
  presence: Presence;
}

const MAX_VISIBLE = 5;

const MEMBERS: Member[] = [
  { id: "salma", name: "Salma Reyes", role: "Product Lead", grad: "from-indigo-500 to-violet-600", presence: "online" },
  { id: "theo", name: "Theo Almeida", role: "Staff Engineer", grad: "from-sky-500 to-indigo-600", presence: "online" },
  { id: "priya", name: "Priya Nair", role: "Design Systems", grad: "from-violet-500 to-rose-500", presence: "idle" },
  { id: "marcus", name: "Marcus Feldt", role: "Motion Designer", grad: "from-emerald-500 to-sky-600", presence: "online" },
  { id: "ines", name: "Ines Kovac", role: "UX Researcher", grad: "from-amber-500 to-rose-500", presence: "offline" },
  { id: "daniel", name: "Daniel Osei", role: "Frontend Engineer", grad: "from-sky-500 to-emerald-500", presence: "online" },
  { id: "yuki", name: "Yuki Tanaka", role: "Accessibility Lead", grad: "from-indigo-500 to-sky-500", presence: "idle" },
  { id: "nadia", name: "Nadia Haddad", role: "Content Design", grad: "from-rose-500 to-amber-500", presence: "online" },
  { id: "oliver", name: "Oliver Brandt", role: "Backend Engineer", grad: "from-zinc-500 to-slate-600", presence: "offline" },
  { id: "chiara", name: "Chiara Russo", role: "Brand Design", grad: "from-violet-500 to-indigo-600", presence: "online" },
  { id: "sam", name: "Sam Okafor", role: "QA Lead", grad: "from-amber-500 to-emerald-600", presence: "idle" },
  { id: "lena", name: "Lena Vogt", role: "Program Manager", grad: "from-emerald-500 to-violet-600", presence: "online" },
];

const FILTERS: { id: Filter; label: string }[] = [
  { id: "all", label: "Everyone" },
  { id: "online", label: "Online" },
  { id: "idle", label: "Idle" },
  { id: "offline", label: "Offline" },
];

const PRESENCE: Record<Presence, { label: string; dot: string; text: string }> = {
  online: { label: "Online", dot: "bg-emerald-500", text: "text-emerald-600 dark:text-emerald-400" },
  idle: { label: "Idle", dot: "bg-amber-500", text: "text-amber-600 dark:text-amber-400" },
  offline: { label: "Offline", dot: "bg-zinc-400 dark:bg-zinc-500", text: "text-zinc-500 dark:text-zinc-400" },
};

function initials(name: string): string {
  return name
    .split(" ")
    .slice(0, 2)
    .map((w) => w[0])
    .join("")
    .toUpperCase();
}

function Monogram({
  member,
  className = "",
}: {
  member: Member;
  className?: string;
}) {
  return (
    <span
      aria-hidden="true"
      className={
        "grid place-items-center rounded-full bg-gradient-to-br font-semibold text-white shadow-sm select-none " +
        member.grad +
        " " +
        className
      }
    >
      {initials(member.name)}
    </span>
  );
}

export default function AvStackedCount() {
  const reduce = useReducedMotion();
  const uid = useId();

  const [filter, setFilter] = useState<Filter>("all");
  const [activeIndex, setActiveIndex] = useState(0);
  const [previewId, setPreviewId] = useState<string | null>(null);
  const [selectedId, setSelectedId] = useState<string>("salma");
  const [reviewerId, setReviewerId] = useState<string | null>(null);
  const [rosterOpen, setRosterOpen] = useState(false);
  const [announce, setAnnounce] = useState("");

  const stackRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const overflowBtnRef = useRef<HTMLButtonElement | null>(null);
  const popoverRef = useRef<HTMLDivElement | null>(null);

  const filtered = useMemo(
    () => (filter === "all" ? MEMBERS : MEMBERS.filter((m) => m.presence === filter)),
    [filter],
  );

  const visible = filtered.slice(0, MAX_VISIBLE);
  const overflow = filtered.length - visible.length;
  const hasOverflow = overflow > 0;
  const itemCount = visible.length + (hasOverflow ? 1 : 0);

  const onlineCount = MEMBERS.filter((m) => m.presence === "online").length;
  const selected = MEMBERS.find((m) => m.id === selectedId) ?? MEMBERS[0];

  const rosterId = `${uid}-roster`;
  const liveId = `${uid}-live`;

  function chooseFilter(next: Filter) {
    setFilter(next);
    setActiveIndex(0);
    setPreviewId(null);
    setRosterOpen(false);
  }

  function focusItem(i: number) {
    if (itemCount === 0) return;
    const clamped = ((i % itemCount) + itemCount) % itemCount;
    setActiveIndex(clamped);
    stackRefs.current[clamped]?.focus();
  }

  function onStackKeyDown(e: ReactKeyboardEvent<HTMLDivElement>) {
    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        e.preventDefault();
        focusItem(activeIndex + 1);
        break;
      case "ArrowLeft":
      case "ArrowUp":
        e.preventDefault();
        focusItem(activeIndex - 1);
        break;
      case "Home":
        e.preventDefault();
        focusItem(0);
        break;
      case "End":
        e.preventDefault();
        focusItem(itemCount - 1);
        break;
      default:
        break;
    }
  }

  function pick(member: Member) {
    setSelectedId(member.id);
    setAnnounce(`${member.name}, ${member.role}, selected.`);
  }

  function toggleReviewer(member: Member) {
    if (reviewerId === member.id) {
      setReviewerId(null);
      setAnnounce("Reviewer cleared.");
    } else {
      setReviewerId(member.id);
      setAnnounce(`${member.name} set as reviewer.`);
    }
  }

  function closeRoster() {
    setRosterOpen(false);
    overflowBtnRef.current?.focus();
  }

  useEffect(() => {
    if (!rosterOpen) return;
    const frame = requestAnimationFrame(() => {
      const first = popoverRef.current?.querySelector<HTMLButtonElement>("[data-roster-item]");
      first?.focus();
    });
    function onDown(e: MouseEvent) {
      if (
        popoverRef.current &&
        !popoverRef.current.contains(e.target as Node) &&
        !overflowBtnRef.current?.contains(e.target as Node)
      ) {
        setRosterOpen(false);
      }
    }
    document.addEventListener("mousedown", onDown);
    return () => {
      cancelAnimationFrame(frame);
      document.removeEventListener("mousedown", onDown);
    };
  }, [rosterOpen]);

  const container: Variants = {
    hidden: {},
    show: { transition: { staggerChildren: reduce ? 0 : 0.055 } },
  };
  const item: Variants = {
    hidden: reduce ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.85 },
    show: {
      opacity: 1,
      y: 0,
      scale: 1,
      transition: { duration: reduce ? 0 : 0.32, ease: "easeOut" },
    },
  };

  const ringBase =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950";

  return (
    <section className="relative w-full overflow-hidden bg-zinc-50 px-5 py-20 text-zinc-900 sm:px-8 dark:bg-zinc-950 dark:text-zinc-50">
      <style>{`
        @keyframes avsc-pulse {
          0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.55); }
          50% { box-shadow: 0 0 0 4px rgba(16, 185, 129, 0); }
        }
        .avsc-live-dot { animation: avsc-pulse 2.4s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .avsc-live-dot { animation: none; }
        }
      `}</style>

      {/* Atmosphere */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0">
        <div className="absolute -left-24 -top-24 h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/10" />
        <div className="absolute -right-16 bottom-0 h-72 w-72 rounded-full bg-violet-400/20 blur-3xl dark:bg-violet-500/10" />
      </div>

      <div className="relative mx-auto w-full max-w-2xl">
        <div className="overflow-hidden rounded-3xl border border-zinc-200/80 bg-white/80 shadow-xl shadow-zinc-900/5 backdrop-blur-sm dark:border-zinc-800 dark:bg-zinc-900/60 dark:shadow-black/40">
          {/* Header */}
          <div className="flex flex-wrap items-start justify-between gap-4 border-b border-zinc-200/70 px-6 py-5 dark:border-zinc-800">
            <div>
              <p className="text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
                Live workspace
              </p>
              <h2 className="mt-1 text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
                Aurora Design System
              </h2>
            </div>
            <span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-zinc-50 px-3 py-1.5 text-xs font-medium text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-300">
              <span className="avsc-live-dot h-2 w-2 rounded-full bg-emerald-500" />
              <span className="tabular-nums">{onlineCount}</span> of{" "}
              <span className="tabular-nums">{MEMBERS.length}</span> online
            </span>
          </div>

          <div className="px-6 py-6">
            {/* Filter segmented control */}
            <div
              className="mb-6 inline-flex flex-wrap gap-1 rounded-xl border border-zinc-200 bg-zinc-100/70 p-1 dark:border-zinc-800 dark:bg-zinc-800/40"
              aria-label="Filter members by presence"
            >
              {FILTERS.map((f) => {
                const on = filter === f.id;
                return (
                  <button
                    key={f.id}
                    type="button"
                    aria-pressed={on}
                    onClick={() => chooseFilter(f.id)}
                    className={
                      "rounded-lg px-3 py-1.5 text-sm font-medium transition-colors " +
                      ringBase +
                      " " +
                      (on
                        ? "bg-white text-zinc-900 shadow-sm dark:bg-zinc-950 dark:text-zinc-50"
                        : "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100")
                    }
                  >
                    {f.label}
                  </button>
                );
              })}
            </div>

            {/* Avatar stack */}
            <div className="relative">
              <p className="mb-3 text-sm text-zinc-500 dark:text-zinc-400">
                {filtered.length === 0
                  ? "No one matches this filter right now."
                  : "Collaborators on this file — use arrow keys to move across the stack."}
              </p>

              <motion.div
                key={filter}
                role="toolbar"
                aria-label="Collaborator avatars"
                aria-orientation="horizontal"
                onKeyDown={onStackKeyDown}
                variants={container}
                initial="hidden"
                animate="show"
                className="flex items-center pl-3"
              >
                {visible.map((m, i) => {
                  const active = i === activeIndex;
                  const lifted = previewId === m.id;
                  return (
                    <motion.div key={m.id} variants={item} className="relative -ml-3 first:ml-0">
                      {/* Tooltip */}
                      <AnimatePresence>
                        {lifted && (
                          <motion.span
                            role="tooltip"
                            id={`${uid}-tip-${m.id}`}
                            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4, scale: 0.96 }}
                            animate={{ opacity: 1, y: 0, scale: 1 }}
                            exit={reduce ? { opacity: 0 } : { opacity: 0, y: 4, scale: 0.96 }}
                            transition={{ duration: reduce ? 0 : 0.16 }}
                            className="pointer-events-none absolute -top-2 left-1/2 z-50 w-max -translate-x-1/2 -translate-y-full rounded-lg bg-zinc-900 px-2.5 py-1.5 text-center text-xs text-white shadow-lg dark:bg-zinc-100 dark:text-zinc-900"
                          >
                            <span className="block font-semibold">{m.name}</span>
                            <span className="block text-zinc-300 dark:text-zinc-600">{m.role}</span>
                          </motion.span>
                        )}
                      </AnimatePresence>

                      <button
                        type="button"
                        ref={(el) => {
                          stackRefs.current[i] = el;
                        }}
                        tabIndex={active ? 0 : -1}
                        aria-pressed={selectedId === m.id}
                        aria-describedby={lifted ? `${uid}-tip-${m.id}` : undefined}
                        aria-label={`${m.name}, ${m.role}, ${PRESENCE[m.presence].label}`}
                        onClick={() => pick(m)}
                        onMouseEnter={() => setPreviewId(m.id)}
                        onMouseLeave={() => setPreviewId((p) => (p === m.id ? null : p))}
                        onFocus={() => {
                          setActiveIndex(i);
                          setPreviewId(m.id);
                        }}
                        onBlur={() => setPreviewId((p) => (p === m.id ? null : p))}
                        style={{ zIndex: lifted ? 40 : visible.length - i }}
                        className={
                          "relative block rounded-full outline-none transition-transform duration-200 hover:-translate-y-1 focus-visible:-translate-y-1 " +
                          ringBase +
                          (selectedId === m.id ? " -translate-y-1" : "")
                        }
                      >
                        <Monogram
                          member={m}
                          className={
                            "h-12 w-12 text-sm ring-[3px] ring-white dark:ring-zinc-900 " +
                            (selectedId === m.id ? "outline outline-2 outline-offset-2 outline-indigo-500" : "")
                          }
                        />
                        {/* Presence dot */}
                        <span
                          className={
                            "absolute bottom-0 right-0 h-3.5 w-3.5 rounded-full ring-2 ring-white dark:ring-zinc-900 " +
                            PRESENCE[m.presence].dot +
                            (m.presence === "online" ? " avsc-live-dot" : "")
                          }
                        />
                        {/* Reviewer badge */}
                        {reviewerId === m.id && (
                          <span className="absolute -right-1 -top-1 grid h-5 w-5 place-items-center rounded-full bg-amber-400 text-zinc-900 ring-2 ring-white dark:ring-zinc-900">
                            <svg viewBox="0 0 24 24" className="h-3 w-3" fill="currentColor" aria-hidden="true">
                              <path d="M12 2.5l2.9 5.9 6.5.95-4.7 4.58 1.11 6.47L12 17.4l-5.81 3.06 1.11-6.47L2.6 9.35l6.5-.95L12 2.5z" />
                            </svg>
                          </span>
                        )}
                      </button>
                    </motion.div>
                  );
                })}

                {/* +N overflow */}
                {hasOverflow && (
                  <motion.div variants={item} className="relative -ml-3">
                    <button
                      type="button"
                      ref={(el) => {
                        stackRefs.current[visible.length] = el;
                      }}
                      tabIndex={activeIndex === visible.length ? 0 : -1}
                      aria-haspopup="dialog"
                      aria-expanded={rosterOpen}
                      aria-controls={rosterId}
                      aria-label={`Show ${overflow} more ${overflow === 1 ? "person" : "people"}`}
                      onClick={() => setRosterOpen((v) => !v)}
                      onFocus={() => setActiveIndex(visible.length)}
                      className={
                        "relative grid h-12 w-12 place-items-center rounded-full bg-zinc-200 text-sm font-semibold text-zinc-700 ring-[3px] ring-white transition-transform duration-200 hover:-translate-y-1 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-100 dark:ring-zinc-900 dark:hover:bg-zinc-600 " +
                        ringBase
                      }
                    >
                      <span className="tabular-nums">+{overflow}</span>
                    </button>

                    {/* Roster popover */}
                    <AnimatePresence>
                      {rosterOpen && (
                        <motion.div
                          ref={popoverRef}
                          id={rosterId}
                          role="dialog"
                          aria-label={`All ${filtered.length} collaborators`}
                          onKeyDown={(e) => {
                            if (e.key === "Escape") {
                              e.stopPropagation();
                              closeRoster();
                            }
                          }}
                          initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.97 }}
                          animate={{ opacity: 1, y: 0, scale: 1 }}
                          exit={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.97 }}
                          transition={{ duration: reduce ? 0 : 0.18, ease: "easeOut" }}
                          className="absolute left-0 top-full z-50 mt-3 max-h-80 w-72 overflow-auto rounded-2xl border border-zinc-200 bg-white p-1.5 shadow-2xl shadow-zinc-900/10 dark:border-zinc-700 dark:bg-zinc-900 dark:shadow-black/50"
                        >
                          <p className="px-3 pb-1.5 pt-2 text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-zinc-400 dark:text-zinc-500">
                            {filtered.length} collaborators
                          </p>
                          <ul className="space-y-0.5">
                            {filtered.map((m) => (
                              <li key={m.id}>
                                <button
                                  type="button"
                                  data-roster-item
                                  onClick={() => {
                                    pick(m);
                                    closeRoster();
                                  }}
                                  className={
                                    "flex w-full items-center gap-3 rounded-xl px-2.5 py-2 text-left transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800 " +
                                    ringBase +
                                    (selectedId === m.id ? " bg-zinc-100 dark:bg-zinc-800" : "")
                                  }
                                >
                                  <span className="relative shrink-0">
                                    <Monogram member={m} className="h-9 w-9 text-xs" />
                                    <span
                                      className={
                                        "absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full ring-2 ring-white dark:ring-zinc-900 " +
                                        PRESENCE[m.presence].dot
                                      }
                                    />
                                  </span>
                                  <span className="min-w-0 flex-1">
                                    <span className="block truncate text-sm font-medium text-zinc-900 dark:text-zinc-50">
                                      {m.name}
                                    </span>
                                    <span className="block truncate text-xs text-zinc-500 dark:text-zinc-400">
                                      {m.role}
                                    </span>
                                  </span>
                                  <span className={"shrink-0 text-xs font-medium " + PRESENCE[m.presence].text}>
                                    {PRESENCE[m.presence].label}
                                  </span>
                                </button>
                              </li>
                            ))}
                          </ul>
                        </motion.div>
                      )}
                    </AnimatePresence>
                  </motion.div>
                )}
              </motion.div>
            </div>

            {/* Selected member detail */}
            <div className="mt-8 rounded-2xl border border-zinc-200/80 bg-zinc-50/70 p-5 dark:border-zinc-800 dark:bg-zinc-800/30">
              <div className="flex items-center gap-4">
                <span className="relative shrink-0">
                  <Monogram member={selected} className="h-14 w-14 text-base" />
                  <span
                    className={
                      "absolute bottom-0 right-0 h-4 w-4 rounded-full ring-2 ring-zinc-50 dark:ring-zinc-900 " +
                      PRESENCE[selected.presence].dot
                    }
                  />
                </span>
                <div className="min-w-0 flex-1">
                  <div className="flex items-center gap-2">
                    <h3 className="truncate text-base font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
                      {selected.name}
                    </h3>
                    {reviewerId === selected.id && (
                      <span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[0.68rem] font-semibold text-amber-700 dark:bg-amber-400/15 dark:text-amber-300">
                        Reviewer
                      </span>
                    )}
                  </div>
                  <p className="truncate text-sm text-zinc-500 dark:text-zinc-400">{selected.role}</p>
                  <p className={"mt-0.5 flex items-center gap-1.5 text-xs font-medium " + PRESENCE[selected.presence].text}>
                    <span className={"h-1.5 w-1.5 rounded-full " + PRESENCE[selected.presence].dot} />
                    {PRESENCE[selected.presence].label}
                  </p>
                </div>
                <button
                  type="button"
                  aria-pressed={reviewerId === selected.id}
                  onClick={() => toggleReviewer(selected)}
                  className={
                    "inline-flex shrink-0 items-center gap-1.5 rounded-xl px-3.5 py-2 text-sm font-semibold transition-colors " +
                    ringBase +
                    " " +
                    (reviewerId === selected.id
                      ? "bg-indigo-600 text-white hover:bg-indigo-500"
                      : "border border-zinc-300 text-zinc-700 hover:bg-white dark:border-zinc-600 dark:text-zinc-200 dark:hover:bg-zinc-800")
                  }
                >
                  <svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true">
                    <path d="M12 2.5l2.9 5.9 6.5.95-4.7 4.58 1.11 6.47L12 17.4l-5.81 3.06 1.11-6.47L2.6 9.35l6.5-.95L12 2.5z" />
                  </svg>
                  {reviewerId === selected.id ? "Reviewer" : "Set reviewer"}
                </button>
              </div>
            </div>

            <p id={liveId} role="status" aria-live="polite" className="sr-only">
              {announce}
            </p>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →