# 아바타 그룹

여러 사람을 겹쳐 보여 주고 넘치는 인원은 숫자로 줄여 알려 줘요.

- Type: component
- Theme: base
- Tags: arc, component, profile, user, team, stack
- English title: Avatar group
- URL: https://dsui.vercel.app/items/avatar-group

## Used items

- [아바타](https://dsui.vercel.app/items/avatar): 사진이나 이름 첫 글자로 사람을 나타내고 상태 점을 달 수 있어요.

## Used by

- [페이지 헤더](https://dsui.vercel.app/items/page-header): 스크롤하면 줄어들고 좁아지면 보조 동작을 더보기로 접는 페이지 머리글이에요.

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| max | slider | 보이는 인원 | 4 |
| size | select | 크기 | md |

## Source

### avatar-group.module.css

```css
.group { --avatar-size: 36px; display: inline-flex; height: var(--avatar-size); align-items: center; padding-left: 6px; }
.group.sm { --avatar-size: 28px; }
.group.lg { --avatar-size: 48px; }
/* Each slot is the avatar's visible width, so a slot that opens or closes moves the rest of the stack smoothly. */
.slot { position: relative; display: inline-flex; flex: 0 0 auto; height: var(--avatar-size); align-items: center; }
/* One wrapper per person carries every hover move as a single translate, so the stack never changes size. */
.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); }
.avatar, .overflow { border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--border); transition: box-shadow var(--duration-fast) var(--ease-standard); }
.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; }
.count { display: inline-block; }
.overflow.sm { width: 28px; height: 28px; font-size: 10px; }
.overflow.md { width: 36px; height: 36px; }
.overflow.lg { width: 48px; height: 48px; font-size: var(--text-sm); }
/* The name floats above the stack, outside the layout, so showing it never moves anything. */
.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); }
/* 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. */
@media (hover: hover) and (pointer: fine) {
  .group:hover .lift { --fan: calc((var(--index) - (var(--count) - 1) / 2) * 4px); }
  .slot:hover { z-index: 1; }
  .slot:hover .lift { --rise: -2px; }
  .slot:hover ~ .slot .lift { --aside: 3px; }
  .slot:has(~ .slot:hover) .lift { --aside: -3px; }
  .slot:hover .avatar, .slot:hover .overflow { box-shadow: 0 0 0 1px var(--border-strong); }
  .slot:hover .tip { opacity: 1; transform: translate(-50%, 0); transition-delay: 60ms; }
}
/* Reduced motion keeps the stack still; the ring and name still answer the pointer. */
@media (prefers-reduced-motion: reduce) { .lift { transform: none !important; transition: none; } .tip { transform: translate(-50%, 0); transition: none; } }

```

### avatar-group.tsx

```tsx
"use client";

import { useState, type CSSProperties } from "react";
import { AnimatePresence, motion, type Variants } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import { Avatar } from "../avatar/avatar";
import styles from "./avatar-group.module.css";

export interface AvatarGroupMember { name: string; src?: string; status?: "online" | "offline"; }
export interface AvatarGroupProps { members: AvatarGroupMember[]; max?: number; size?: "sm" | "md" | "lg"; label?: string; }

/** The overflow count rolls the way it moved: more people rise in from below, fewer drop in from above. */
const 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] } }) };
// Same keys as `rise` so the settled style is identical whichever branch renders on the server.
const 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 } } };
// A person joining or leaving opens or closes their slot, so the rest of the stack slides instead of jumping.
const slot = { initial: { width: 0, opacity: 0, scale: .9 }, animate: { width: "auto", opacity: 1, scale: 1 }, exit: { width: 0, opacity: 0, scale: .9 } };

export function AvatarGroup({ members, max = 4, size = "md", label = "팀원" }: AvatarGroupProps) {
  const reduceMotion = !!useReducedMotion();
  const visible = members.slice(0, Math.max(0, max));
  const overflow = Math.max(0, members.length - visible.length);
  const transition = reduceMotion ? { duration: 0 } : motionTokens.spring.morph;
  const [count, setCount] = useState({ overflow, direction: 1 });
  if (count.overflow !== overflow) setCount({ overflow, direction: overflow < count.overflow ? -1 : 1 });
  return <div className={[styles.group, styles[size]].join(" ")} role="group" aria-label={label} style={{ "--count": visible.length + (overflow > 0 ? 1 : 0) } as CSSProperties}>
    <AnimatePresence initial={false}>
      {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>)}
      {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={`${label} ${overflow}명 더 있어요`}><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}
    </AnimatePresence>
  </div>;
}

export default AvatarGroup;

```
