UIData Displayno dependencies

AvatarGroup

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 props
5
0.3

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add avatar-group

Writes 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.

example.tsx
1import { AvatarGroup } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <AvatarGroup />6  );7}

Props

PropTypeDefault
names

People in the stack; initials and gradient are derived deterministically from each name.

string[]['Ava Thompson', 'Liam Chen', ...] (9 names)
max

Maximum avatars shown before the rest collapse into a +N chip.

number5
size

Avatar size preset (32 / 40 / 52px).

'sm' | 'md' | 'lg''md'
overlap

Overlap between neighbours as a fraction of the avatar size (0-0.6).

number0.3
spreadOnHover

Fan the avatars apart while the group is hovered or focused.

booleantrue
showStatus

Show a deterministic presence dot (online pings softly).

booleantrue
showTooltip

Show the full name in a tooltip on hover or focus.

booleantrue
accentColor

Tint of the +N overflow chip.

string'#6366f1'
label

Accessible name of the group.

string'Project members'
onExpandedChange

Called when the +N chip expands or collapses the full list.

(expanded: boolean) => voidundefined
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/avatar-group.tsx
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}