An overlapping stack of initials avatars with deterministic per-name gradients that cascade in, lift on hover and fan apart when the group is hovered or focused. Extra people collapse into an accent +N chip that expands the full list, with optional presence dots and name tooltips.
Preview
Customize
9 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add avatar-groupWrites components/ui/avatar-group.tsx and components/styles/avatar-group.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { AvatarGroup } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <AvatarGroup />6 );7}Props
Accessibility
The root is a role="group" with an aria-label; each avatar is a focusable role="img" whose aria-label includes the name and presence status (initials, dot and tooltip are aria-hidden). The +N chip is a native button with aria-expanded and an aria-label listing the hidden names. Focus shows the tooltip and spreads the stack; under prefers-reduced-motion the entrance, lift, spread and status ping are disabled.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/avatar-group.module.css";5 6export interface AvatarGroupProps {7 /** People shown in the stack (initials are derived from each name) */8 names?: string[];9 /** Maximum avatars shown before collapsing the rest into a +N chip */10 max?: number;11 /** Avatar size preset */12 size?: "sm" | "md" | "lg";13 /** How much neighbouring avatars overlap, as a fraction of the avatar size (0–0.6) */14 overlap?: number;15 /** Fan the stack apart while the group is hovered or focused */16 spreadOnHover?: boolean;17 /** Show a deterministic presence dot (online / away / busy / offline) on each avatar */18 showStatus?: boolean;19 /** Show the person's name in a small tooltip on hover / focus */20 showTooltip?: boolean;21 /** Accent color used by the +N chip and focus tint */22 accentColor?: string;23 /** Accessible name of the group */24 label?: string;25 /** Called when the +N chip toggles the full list */26 onExpandedChange?: (expanded: boolean) => void;27 /** Optional className merged onto the root */28 className?: string;29 /** Optional inline styles merged onto the root */30 style?: React.CSSProperties;31}32 33const DEFAULT_NAMES = [34 "Ava Thompson",35 "Liam Chen",36 "Sofia Martinez",37 "Noah Patel",38 "Maya Okafor",39 "Ethan Brooks",40 "Isla Novak",41 "Kai Tanaka",42 "Zara Ahmed",43];44 45const STATUSES = ["online", "away", "online", "busy", "offline", "online", "away", "online", "busy"] as const;46 47function hash(s: string) {48 let h = 2166136261;49 for (let i = 0; i < s.length; i++) {50 h ^= s.charCodeAt(i);51 h = Math.imul(h, 16777619);52 }53 return h >>> 0;54}55 56function initials(name: string) {57 const parts = name.trim().split(/\s+/).filter(Boolean);58 if (parts.length === 0) return "?";59 const first = parts[0]?.[0] ?? "";60 const last = parts.length > 1 ? parts[parts.length - 1]?.[0] ?? "" : parts[0]?.[1] ?? "";61 return (first + last).toUpperCase();62}63 64function gradientFor(name: string) {65 const h = hash(name);66 const hue = h % 360;67 const hue2 = (hue + 35 + ((h >>> 9) % 50)) % 360;68 const angle = 120 + ((h >>> 3) % 120);69 return `linear-gradient(${angle}deg, hsl(${hue} 78% 62%), hsl(${hue2} 72% 46%))`;70}71 72export function AvatarGroup({73 names = DEFAULT_NAMES,74 max = 5,75 size = "md",76 overlap = 0.3,77 spreadOnHover = true,78 showStatus = true,79 showTooltip = true,80 accentColor = "#6366f1",81 label = "Project members",82 onExpandedChange,83 className = "",84 style,85}: AvatarGroupProps) {86 const [expanded, setExpanded] = React.useState(false);87 const limit = Math.max(1, Math.floor(max));88 const visible = expanded ? names : names.slice(0, limit);89 const hidden = names.length - visible.length;90 const ov = Math.min(0.6, Math.max(0, overlap));91 92 const toggle = () => {93 const next = !expanded;94 setExpanded(next);95 onExpandedChange?.(next);96 };97 98 return (99 <div100 role="group"101 aria-label={label}102 className={`${styles.root} ${className}`.trim()}103 data-size={size}104 data-spread={spreadOnHover ? "" : undefined}105 style={{ "--mf-ag-accent": accentColor, "--mf-ag-overlap": ov, ...style } as React.CSSProperties}106 >107 <ul className={styles.list}>108 {visible.map((name, i) => {109 const status = STATUSES[i % STATUSES.length] ?? "online";110 const desc = showStatus ? `${name} (${status})` : name;111 return (112 <li key={`${name}-${i}`} className={styles.item} style={{ "--mf-ag-i": i, zIndex: visible.length - i } as React.CSSProperties}>113 <span role="img" aria-label={desc} tabIndex={0} className={styles.avatar} style={{ backgroundImage: gradientFor(name) }}>114 <span aria-hidden="true" className={styles.initials}>115 {initials(name)}116 </span>117 {showStatus && <span aria-hidden="true" className={styles.status} data-status={status} />}118 </span>119 {showTooltip && (120 <span aria-hidden="true" className={styles.tip}>121 {name}122 </span>123 )}124 </li>125 );126 })}127 {(hidden > 0 || expanded) && names.length > limit && (128 <li className={styles.item} style={{ "--mf-ag-i": visible.length, zIndex: 0 } as React.CSSProperties}>129 <button130 type="button"131 className={`${styles.avatar} ${styles.more}`}132 aria-expanded={expanded}133 aria-label={expanded ? "Show fewer members" : `Show ${hidden} more: ${names.slice(limit).join(", ")}`}134 onClick={toggle}135 >136 {expanded ? (137 <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">138 <path d="M15 6l-6 6 6 6" />139 </svg>140 ) : (141 `+${hidden}`142 )}143 </button>144 </li>145 )}146 </ul>147 </div>148 );149}