Group Avatar
Original · freeoverlapping avatar group
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-group.json"use client";
import { useState, useRef, useEffect, useMemo, type KeyboardEvent } from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";
type Team = "Design" | "Engineering" | "Product";
type Status = "online" | "away" | "offline";
type Member = {
id: string;
name: string;
role: string;
team: Team;
status: Status;
gradient: string;
};
const MEMBERS: Member[] = [
{ id: "m1", name: "Maya Okonkwo", role: "Staff Product Designer", team: "Design", status: "online", gradient: "from-indigo-500 to-violet-500" },
{ id: "m2", name: "Devon Reyes", role: "Frontend Engineer", team: "Engineering", status: "online", gradient: "from-emerald-500 to-sky-500" },
{ id: "m3", name: "Priya Nair", role: "Design Systems Lead", team: "Design", status: "away", gradient: "from-rose-500 to-amber-500" },
{ id: "m4", name: "Tomás Beck", role: "Backend Engineer", team: "Engineering", status: "online", gradient: "from-sky-500 to-indigo-500" },
{ id: "m5", name: "Amara Sesay", role: "Product Manager", team: "Product", status: "online", gradient: "from-amber-500 to-rose-500" },
{ id: "m6", name: "Jonah Feldman", role: "QA Engineer", team: "Engineering", status: "offline", gradient: "from-violet-500 to-indigo-500" },
{ id: "m7", name: "Lena Vasquez", role: "UX Researcher", team: "Design", status: "away", gradient: "from-sky-500 to-emerald-500" },
{ id: "m8", name: "Ravi Menon", role: "Platform Engineer", team: "Engineering", status: "online", gradient: "from-rose-500 to-violet-500" },
{ id: "m9", name: "Chloe Ashford", role: "Group Product Manager", team: "Product", status: "online", gradient: "from-amber-500 to-emerald-500" },
];
const OVERFLOW = "__overflow__";
const VISIBLE_COUNT = 6;
const STATUS_META: Record<Status, { label: string; dot: string; pulse: boolean }> = {
online: { label: "Online", dot: "bg-emerald-500", pulse: true },
away: { label: "Away", dot: "bg-amber-500", pulse: false },
offline: { label: "Offline", dot: "bg-slate-400", pulse: false },
};
const TEAM_PILL: Record<Team, string> = {
Design: "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300",
Engineering: "bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
Product: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
};
function initials(name: string): string {
return name
.split(" ")
.map((w) => w[0])
.slice(0, 2)
.join("")
.toUpperCase();
}
export default function AvatarGroup() {
const reduce = useReducedMotion();
const [selected, setSelected] = useState<Set<string>>(new Set(["m1", "m5"]));
const [activeId, setActiveId] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
const [panelOpen, setPanelOpen] = useState(false);
const [focusIndex, setFocusIndex] = useState(0);
const btnRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const panelRef = useRef<HTMLDivElement | null>(null);
const visible = useMemo(() => MEMBERS.slice(0, VISIBLE_COUNT), []);
const hiddenCount = MEMBERS.length - visible.length;
const orderedIds = useMemo(() => {
const ids = visible.map((m) => m.id);
if (hiddenCount > 0) ids.push(OVERFLOW);
return ids;
}, [visible, hiddenCount]);
const selectedMembers = useMemo(() => MEMBERS.filter((m) => selected.has(m.id)), [selected]);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const clearAll = () => setSelected(new Set());
const moveFocus = (nextRaw: number) => {
const len = orderedIds.length;
const idx = ((nextRaw % len) + len) % len;
setFocusIndex(idx);
const id = orderedIds[idx];
btnRefs.current[id]?.focus();
};
const onGroupKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault();
moveFocus(focusIndex + 1);
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
moveFocus(focusIndex - 1);
break;
case "Home":
e.preventDefault();
moveFocus(0);
break;
case "End":
e.preventDefault();
moveFocus(orderedIds.length - 1);
break;
default:
break;
}
};
useEffect(() => {
if (!panelOpen) return;
const onPointerDown = (e: PointerEvent) => {
const target = e.target as Node;
if (panelRef.current?.contains(target)) return;
if (btnRefs.current[OVERFLOW]?.contains(target)) return;
setPanelOpen(false);
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [panelOpen]);
useEffect(() => {
if (!panelOpen) return;
const first = panelRef.current?.querySelector<HTMLButtonElement>("[data-roster-item]");
first?.focus();
}, [panelOpen]);
const closePanel = () => {
setPanelOpen(false);
btnRefs.current[OVERFLOW]?.focus();
};
const dur = reduce ? 0 : 0.18;
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-6 py-24 text-slate-900 dark:from-slate-950 dark:to-slate-900 dark:text-slate-100">
<style>{`
@keyframes avgrp-pop {
from { transform: scale(0); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes avgrp-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); }
}
.avgrp-pop { animation: avgrp-pop 0.28s cubic-bezier(0.34,1.56,0.64,1) both; }
.avgrp-pulse { animation: avgrp-pulse 2.4s ease-out infinite; }
@media (prefers-reduced-motion: reduce) {
.avgrp-pop, .avgrp-pulse { animation: none !important; }
.avgrp-av { transition: none !important; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 left-1/2 -z-0 h-72 w-[36rem] -translate-x-1/2 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/20"
/>
<div className="relative z-10 mx-auto max-w-3xl">
<header className="mb-8">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium tracking-wide text-slate-600 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300">
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5 text-indigo-500" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M6 3v12" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="6" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</svg>
Pull request #482
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">Assign reviewers</h2>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Pick who signs off on the <span className="font-mono text-slate-800 dark:text-slate-200">checkout-refactor</span> branch. Hover a teammate for details; the more people you add, the faster it ships.
</p>
</header>
<div className="rounded-3xl border border-slate-200 bg-white/80 p-6 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/70 sm:p-8">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Reviewers</span>
<span
className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold tabular-nums text-slate-600 dark:bg-slate-800 dark:text-slate-300"
aria-hidden="true"
>
{selected.size} of {MEMBERS.length}
</span>
</div>
{/* Overlapping avatar group */}
<div className="relative mt-5">
<div
role="group"
aria-label="Team members — arrow keys to move, Enter or Space to assign"
onKeyDown={onGroupKeyDown}
onMouseEnter={() => setExpanded(true)}
onMouseLeave={() => setExpanded(false)}
onFocus={() => setExpanded(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setExpanded(false);
}}
className="flex items-center pl-1"
>
{visible.map((m, index) => {
const isSelected = selected.has(m.id);
const isActive = activeId === m.id;
const st = STATUS_META[m.status];
const orderIdx = orderedIds.indexOf(m.id);
return (
<div
key={m.id}
className="avgrp-av relative transition-[margin] duration-200"
style={{
marginLeft: index === 0 ? 0 : expanded ? 6 : -14,
zIndex: isActive ? 40 : visible.length - index,
}}
>
<button
type="button"
ref={(el) => {
btnRefs.current[m.id] = el;
}}
tabIndex={orderedIds[focusIndex] === m.id ? 0 : -1}
aria-pressed={isSelected}
aria-describedby={isActive ? `avgrp-tip-${m.id}` : undefined}
aria-label={`${m.name}, ${m.role}. ${st.label}. ${isSelected ? "Assigned reviewer" : "Not assigned"}. Activate to ${isSelected ? "remove" : "assign"}.`}
onClick={() => {
toggle(m.id);
setFocusIndex(orderIdx);
}}
onMouseEnter={() => setActiveId(m.id)}
onMouseLeave={() => setActiveId((cur) => (cur === m.id ? null : cur))}
onFocus={() => {
setActiveId(m.id);
setFocusIndex(orderIdx);
}}
onBlur={() => setActiveId((cur) => (cur === m.id ? null : cur))}
className={`avgrp-av group/av relative grid h-12 w-12 shrink-0 place-items-center rounded-full bg-gradient-to-br ${m.gradient} text-sm font-semibold text-white outline-none ring-4 transition-transform duration-200 hover:scale-110 focus-visible:scale-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 ${isSelected ? "ring-indigo-500" : "ring-white dark:ring-slate-900"}`}
>
<span aria-hidden="true" className="select-none">
{initials(m.name)}
</span>
{/* Status dot */}
<span
aria-hidden="true"
className={`absolute -right-0.5 -top-0.5 h-3.5 w-3.5 rounded-full ring-2 ring-white dark:ring-slate-900 ${st.dot} ${st.pulse ? "avgrp-pulse" : ""}`}
/>
{/* Selected check badge */}
{isSelected && (
<span
aria-hidden="true"
className="avgrp-pop absolute -bottom-0.5 -right-0.5 grid h-5 w-5 place-items-center rounded-full bg-indigo-500 text-white ring-2 ring-white dark:ring-slate-900"
>
<svg viewBox="0 0 24 24" className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
)}
{/* Tooltip */}
<AnimatePresence>
{isActive && (
<motion.span
id={`avgrp-tip-${m.id}`}
role="tooltip"
initial={{ opacity: 0, y: reduce ? 0 : 4, scale: reduce ? 1 : 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: reduce ? 0 : 4, scale: reduce ? 1 : 0.96 }}
transition={{ duration: dur, ease: "easeOut" }}
className="pointer-events-none absolute bottom-full left-1/2 mb-3 -translate-x-1/2 whitespace-nowrap rounded-lg bg-slate-900 px-2.5 py-1.5 text-left shadow-lg dark:bg-slate-100"
>
<span className="block text-xs font-semibold text-white dark:text-slate-900">{m.name}</span>
<span className="block text-[11px] text-slate-300 dark:text-slate-600">{m.role}</span>
<span
aria-hidden="true"
className="absolute left-1/2 top-full h-2 w-2 -translate-x-1/2 -translate-y-1 rotate-45 bg-slate-900 dark:bg-slate-100"
/>
</motion.span>
)}
</AnimatePresence>
</button>
</div>
);
})}
{/* Overflow button */}
{hiddenCount > 0 && (
<div
className="avgrp-av relative transition-[margin] duration-200"
style={{ marginLeft: expanded ? 6 : -14, zIndex: 1 }}
>
<button
type="button"
ref={(el) => {
btnRefs.current[OVERFLOW] = el;
}}
tabIndex={orderedIds[focusIndex] === OVERFLOW ? 0 : -1}
aria-expanded={panelOpen}
aria-controls="avgrp-roster"
aria-label={`Show all ${MEMBERS.length} team members`}
onClick={() => {
setPanelOpen((v) => !v);
setFocusIndex(orderedIds.indexOf(OVERFLOW));
}}
className="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-slate-100 text-sm font-semibold text-slate-600 outline-none ring-4 ring-white transition-transform duration-200 hover:scale-110 hover:bg-slate-200 focus-visible:scale-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-900 dark:hover:bg-slate-700"
>
+{hiddenCount}
</button>
</div>
)}
</div>
{/* Roster popover */}
<AnimatePresence>
{panelOpen && (
<motion.div
ref={panelRef}
id="avgrp-roster"
role="group"
aria-label="All team members"
initial={{ opacity: 0, y: reduce ? 0 : -6, scale: reduce ? 1 : 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: reduce ? 0 : -6, scale: reduce ? 1 : 0.97 }}
transition={{ duration: dur, ease: "easeOut" }}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.stopPropagation();
closePanel();
}
}}
className="absolute left-0 top-full z-50 mt-3 w-80 origin-top-left overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl dark:border-slate-700 dark:bg-slate-800"
>
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3 dark:border-slate-700">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Everyone</span>
<button
type="button"
onClick={closePanel}
aria-label="Close list"
className="grid h-6 w-6 place-items-center rounded-md text-slate-400 outline-none transition-colors hover:bg-slate-100 hover:text-slate-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-sky-500 dark:hover:bg-slate-700 dark:hover:text-slate-200"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
<ul className="max-h-72 overflow-y-auto p-1.5">
{MEMBERS.map((m) => {
const isSelected = selected.has(m.id);
const st = STATUS_META[m.status];
return (
<li key={m.id}>
<button
type="button"
data-roster-item
aria-pressed={isSelected}
onClick={() => toggle(m.id)}
className="flex w-full items-center gap-3 rounded-xl px-2.5 py-2 text-left outline-none transition-colors hover:bg-slate-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-sky-500 dark:hover:bg-slate-700/60"
>
<span className={`relative grid h-9 w-9 shrink-0 place-items-center rounded-full bg-gradient-to-br ${m.gradient} text-xs font-semibold text-white`}>
{initials(m.name)}
<span aria-hidden="true" className={`absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full ring-2 ring-white dark:ring-slate-800 ${st.dot}`} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-slate-800 dark:text-slate-100">{m.name}</span>
<span className="mt-0.5 flex items-center gap-1.5">
<span className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${TEAM_PILL[m.team]}`}>{m.team}</span>
<span className="truncate text-xs text-slate-500 dark:text-slate-400">{m.role}</span>
</span>
</span>
<span
aria-hidden="true"
className={`grid h-5 w-5 shrink-0 place-items-center rounded-full border transition-colors ${
isSelected
? "border-indigo-500 bg-indigo-500 text-white"
: "border-slate-300 bg-transparent text-transparent dark:border-slate-600"
}`}
>
<svg viewBox="0 0 24 24" className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
</button>
</li>
);
})}
</ul>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Selected summary */}
<div className="mt-6 border-t border-slate-100 pt-5 dark:border-slate-800">
<div className="flex items-center justify-between">
<p aria-live="polite" className="text-sm text-slate-600 dark:text-slate-400">
{selected.size === 0 ? (
"No reviewers assigned yet."
) : (
<>
<span className="font-semibold text-slate-900 dark:text-slate-100">{selected.size}</span>{" "}
{selected.size === 1 ? "reviewer" : "reviewers"} assigned
</>
)}
</p>
{selected.size > 0 && (
<button
type="button"
onClick={clearAll}
className="rounded-md px-2 py-1 text-xs font-medium text-slate-500 outline-none transition-colors hover:text-rose-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-sky-500 dark:text-slate-400 dark:hover:text-rose-400"
>
Clear all
</button>
)}
</div>
{selectedMembers.length > 0 && (
<ul className="mt-3 flex flex-wrap gap-2">
<AnimatePresence initial={false}>
{selectedMembers.map((m) => (
<motion.li
key={m.id}
layout={!reduce}
initial={{ opacity: 0, scale: reduce ? 1 : 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: reduce ? 1 : 0.85 }}
transition={{ duration: dur, ease: "easeOut" }}
>
<span className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 bg-slate-50 py-1 pl-1 pr-1 text-sm dark:border-slate-700 dark:bg-slate-800/60">
<span className={`grid h-6 w-6 place-items-center rounded-full bg-gradient-to-br ${m.gradient} text-[10px] font-semibold text-white`}>
{initials(m.name)}
</span>
<span className="text-slate-700 dark:text-slate-200">{m.name.split(" ")[0]}</span>
<button
type="button"
onClick={() => toggle(m.id)}
aria-label={`Remove ${m.name} from reviewers`}
className="grid h-5 w-5 place-items-center rounded-full text-slate-400 outline-none transition-colors hover:bg-slate-200 hover:text-slate-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-sky-500 dark:hover:bg-slate-700 dark:hover:text-slate-100"
>
<svg viewBox="0 0 24 24" className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</span>
</motion.li>
))}
</AnimatePresence>
</ul>
)}
<p className="mt-4 text-xs text-slate-400 dark:text-slate-500">
Tip: focus the group and use <kbd className="rounded border border-slate-300 px-1 font-mono text-[10px] dark:border-slate-600">←</kbd>{" "}
<kbd className="rounded border border-slate-300 px-1 font-mono text-[10px] dark:border-slate-600">→</kbd> to move, <kbd className="rounded border border-slate-300 px-1 font-mono text-[10px] dark:border-slate-600">Enter</kbd> to assign.
</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

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

Stacked Count Avatar
Originalavatar stack with +N count

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.

