{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "avatar-group",
  "type": "registry:ui",
  "title": "Avatar group",
  "description": "Show a team or set of contributors in a small space.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json",
    "https://mcp.xzero.co.uk/r/avatar.json"
  ],
  "files": [
    {
      "path": "registry/components/avatar-group/avatar-group.tsx",
      "type": "registry:component",
      "target": "@components/xzero/avatar-group/avatar-group.tsx",
      "content": "\"use client\";\n\nimport { useState, type CSSProperties } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion, type Variants } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { Avatar } from \"../avatar/avatar\";\nimport styles from \"./avatar-group.module.css\";\n\nexport interface AvatarGroupMember { name: string; src?: string; status?: \"online\" | \"offline\"; }\nexport interface AvatarGroupProps { members: AvatarGroupMember[]; max?: number; size?: \"sm\" | \"md\" | \"lg\"; label?: string; }\n\n/** The overflow count rolls the way it moved: more people rise in from below, fewer drop in from above. */\nconst rise: Variants = { hidden: (direction: number) => ({ opacity: 0, y: `${.4 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)` }), shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }, gone: (direction: number) => ({ opacity: 0, y: `${-.4 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }) };\n// Same keys as `rise` so the settled style is identical whichever branch renders on the server.\nconst fade: Variants = { hidden: { opacity: 0, y: 0, filter: \"blur(0px)\" }, shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } } };\n// A person joining or leaving opens or closes their slot, so the rest of the stack slides instead of jumping.\nconst slot = { initial: { width: 0, opacity: 0, scale: .9 }, animate: { width: \"auto\", opacity: 1, scale: 1 }, exit: { width: 0, opacity: 0, scale: .9 } };\n\nexport function AvatarGroup({ members, max = 4, size = \"md\", label = \"Team members\" }: AvatarGroupProps) {\n  const reduceMotion = !!useReducedMotion();\n  const visible = members.slice(0, Math.max(0, max));\n  const overflow = Math.max(0, members.length - visible.length);\n  const transition = reduceMotion ? { duration: 0 } : motionTokens.spring.morph;\n  const [count, setCount] = useState({ overflow, direction: 1 });\n  if (count.overflow !== overflow) setCount({ overflow, direction: overflow < count.overflow ? -1 : 1 });\n  return <div className={[styles.group, styles[size]].join(\" \")} role=\"group\" aria-label={label} style={{ \"--count\": visible.length + (overflow > 0 ? 1 : 0) } as CSSProperties}>\n    <AnimatePresence initial={false}>\n      {visible.map((member, index) => <motion.span key={member.name} className={styles.slot} style={{ \"--index\": index } as CSSProperties} {...slot} transition={transition}><span className={styles.lift}><Avatar className={styles.avatar} name={member.name} src={member.src} status={member.status} size={size} /><span className={styles.tip} aria-hidden=\"true\">{member.name}</span></span></motion.span>)}\n      {overflow > 0 ? <motion.span key=\"overflow\" className={styles.slot} style={{ \"--index\": visible.length } as CSSProperties} {...slot} transition={transition}><span className={[styles.lift, styles.overflow, styles[size]].join(\" \")} role=\"img\" aria-label={`${overflow} more ${label.toLowerCase()}`}><AnimatePresence mode=\"popLayout\" initial={false} custom={count.direction}><motion.span key={overflow} className={styles.count} custom={count.direction} variants={reduceMotion ? fade : rise} initial=\"hidden\" animate=\"shown\" exit=\"gone\" aria-hidden=\"true\">+{overflow}</motion.span></AnimatePresence></span></motion.span> : null}\n    </AnimatePresence>\n  </div>;\n}\n\nexport default AvatarGroup;\n"
    },
    {
      "path": "registry/components/avatar-group/avatar-group.module.css",
      "type": "registry:component",
      "target": "@components/xzero/avatar-group/avatar-group.module.css",
      "content": ".group { --avatar-size: 36px; display: inline-flex; height: var(--avatar-size); align-items: center; padding-left: 6px; }\n.group.sm { --avatar-size: 28px; }\n.group.lg { --avatar-size: 48px; }\n/* Each slot is the avatar's visible width, so a slot that opens or closes moves the rest of the stack smoothly. */\n.slot { position: relative; display: inline-flex; flex: 0 0 auto; height: var(--avatar-size); align-items: center; }\n/* One wrapper per person carries every hover move as a single translate, so the stack never changes size. */\n.lift { --fan: 0px; --aside: 0px; --rise: 0px; position: relative; display: inline-flex; margin-left: -6px; border-radius: var(--radius-pill); transform: translate(calc(var(--fan) + var(--aside)), var(--rise)); transition: transform var(--duration-standard) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }\n.avatar, .overflow { border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--border); transition: box-shadow var(--duration-fast) var(--ease-standard); }\n.overflow { position: relative; display: inline-grid; place-items: center; overflow: hidden; border-radius: var(--radius-pill); background: var(--surface-muted); color: var(--text-secondary); font-size: var(--text-xs); font-weight: 500; font-variant-numeric: tabular-nums; }\n.count { display: inline-block; }\n.overflow.sm { width: 28px; height: 28px; font-size: 10px; }\n.overflow.md { width: 36px; height: 36px; }\n.overflow.lg { width: 48px; height: 48px; font-size: var(--text-sm); }\n/* The name floats above the stack, outside the layout, so showing it never moves anything. */\n.tip { position: absolute; z-index: 2; bottom: calc(100% + 8px); left: 50%; padding: 4px 8px; border-radius: var(--radius-pill); background: var(--foreground); color: var(--background); font-size: var(--text-xs); font-weight: 500; line-height: var(--leading-body); white-space: nowrap; pointer-events: none; opacity: 0; transform: translate(-50%, 3px); transition: opacity var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard); }\n/* Pointing at the stack loosens its overlap from the center; the person under the pointer lifts with a crisp ring and names themselves, and their neighbors ease aside. */\n@media (hover: hover) and (pointer: fine) {\n  .group:hover .lift { --fan: calc((var(--index) - (var(--count) - 1) / 2) * 4px); }\n  .slot:hover { z-index: 1; }\n  .slot:hover .lift { --rise: -2px; }\n  .slot:hover ~ .slot .lift { --aside: 3px; }\n  .slot:has(~ .slot:hover) .lift { --aside: -3px; }\n  .slot:hover .avatar, .slot:hover .overflow { box-shadow: 0 0 0 1px var(--border-strong); }\n  .slot:hover .tip { opacity: 1; transform: translate(-50%, 0); transition-delay: 60ms; }\n}\n/* Reduced motion keeps the stack still; the ring and name still answer the pointer. */\n@media (prefers-reduced-motion: reduce) { .lift { transform: none !important; transition: none; } .tip { transform: translate(-50%, 0); transition: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/avatar-group",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "identity",
      "people"
    ],
    "docs": "https://ui.xzero.co.uk/components/avatar-group",
    "markdown": "https://ui.xzero.co.uk/components/avatar-group/markdown"
  }
}