Stacked Count Avatar
Original · freeavatar stack with +N count
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"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
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 quoteSimilar components
Browse all →
Basic Avatar
Originalavatars in sizes with fallback

Group Avatar
Originaloverlapping avatar group

Status Avatar
Originalavatars with online status dot

Ring Avatar
Originalavatars with gradient ring

Initials Avatar
Originalinitials avatars with colours

Shapes Avatar
Originalavatars in circle/square/squircle

Upload Avatar
Originalavatar with upload/edit overlay

With Name Avatar
Originalavatar with name and role

Squircle Avatar
Originalsquircle avatars with badge

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

