Web InnoventixFreeCode

Group Avatar

Original · free

overlapping avatar group

byWeb InnoventixReact + Tailwind
avgroupavatars
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-group.json
av-group.tsx
"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

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 →