# 아바타

사진이나 이름 첫 글자로 사람을 나타내고 상태 점을 달 수 있어요.

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

## Used by

- [아바타 그룹](https://dsui.vercel.app/items/avatar-group): 여러 사람을 겹쳐 보여 주고 넘치는 인원은 숫자로 줄여 알려 줘요.
- [문의 섹션](https://dsui.vercel.app/items/contact-section): 문의 양식, 연락 채널, 지점 안내 중에서 고를 수 있는 연락처 섹션이에요.
- [히어로 섹션](https://dsui.vercel.app/items/hero-section): 가운데, 분할, 미니멀 세 가지 모양의 첫 화면 소개 섹션이에요.
- [알림 센터](https://dsui.vercel.app/items/notification-center): 전체와 안 읽음으로 나눠 알림을 보고 읽음 표시나 삭제를 하는 팝오버예요.
- [페이지 헤더](https://dsui.vercel.app/items/page-header): 스크롤하면 줄어들고 좁아지면 보조 동작을 더보기로 접는 페이지 머리글이에요.
- [로그인](https://dsui.vercel.app/items/sign-in): 이메일 코드, 패스키, 소셜 계정으로 단계에 따라 로그인하는 블록이에요.

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| name | text | 이름 | 김서연 |
| size | select | 크기 | lg |
| status | select | 상태 | none |

## Source

### avatar.module.css

```css
.avatar {
  position: relative;
  display: inline-grid;
  flex: 0 0 auto;
  place-items: center;
  overflow: visible;
  border: 1px solid var(--border);
  border-radius: var(--radius-pill);
  background: var(--surface-muted);
  color: var(--foreground);
  font-size: var(--text-xs);
  font-weight: var(--label-weight);
}

.avatar img {
  pointer-events: none;
  border-radius: inherit;
  object-fit: cover;
  transition: opacity var(--duration-standard) var(--ease-enter), filter var(--duration-standard) var(--ease-enter);
}

/* Set only while a photo is still downloading after mount, so cached photos never fade. */
.avatar img[data-loading] {
  opacity: 0;
  filter: blur(4px);
  transition: none;
}

.fallback { animation: avatar-fallback-in var(--duration-standard) var(--ease-enter) both; }

.sm { width: 28px; height: 28px; font-size: 10px; }
.md { width: 36px; height: 36px; }
.lg { width: 48px; height: 48px; font-size: var(--text-sm); }
.xl { width: 88px; height: 88px; font-size: var(--text-xl); }

.status {
  position: absolute;
  right: 0;
  bottom: 0;
  z-index: 2;
  width: 10px;
  height: 10px;
  border: 2px solid var(--surface);
  border-radius: var(--radius-pill);
  box-shadow: 0 0 0 1px color-mix(in oklab, var(--border) 70%, transparent);
}

.sm .status { width: 8px; height: 8px; border-width: 1.5px; }
.lg .status { width: 12px; height: 12px; }
.xl .status { width: 14px; height: 14px; right: 4px; bottom: 4px; }
.online { background: var(--success); }
.offline { background: var(--text-muted); }

@keyframes avatar-fallback-in { from { opacity: 0; } to { opacity: 1; } }

@media (prefers-reduced-motion: reduce) {
  .avatar img { transition: none; }
  .avatar img[data-loading] { filter: none; }
  .fallback { animation: none; }
}

```

### avatar.tsx

```tsx
"use client";

import { useLayoutEffect, useRef, useState, type HTMLAttributes } from "react";
import { AnimatePresence, motion } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { ArcImage } from "../lib/arc-provider";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./avatar.module.css";
export interface AvatarProps extends HTMLAttributes<HTMLSpanElement> { name: string; src?: string; size?: "sm" | "md" | "lg" | "xl"; status?: "online" | "offline" }
const statusLabel = { online: "접속 중", offline: "오프라인" } as const;
export function Avatar({ name, src, size = "md", status, className, ...props }: AvatarProps) {
  const initials = name.trim().split(/\s+/).slice(0, 2).map(part => part[0]?.toUpperCase()).join("");
  const reduceMotion = !!useReducedMotion();
  const image = useRef<HTMLImageElement>(null);
  const [failedSrc, setFailedSrc] = useState<string>();
  // A photo that is already decoded shows at once. One that is still loading waits, then fades in from a soft blur.
  useLayoutEffect(() => { const node = image.current; if (node && !node.complete) node.dataset.loading = ""; }, [src]);
  const showImage = src && failedSrc !== src;
  return <span {...props} className={[styles.avatar, styles[size], className].filter(Boolean).join(" ")} role="img" aria-label={`${name}${status ? `, ${statusLabel[status]}` : ""}`}>
    {showImage ? <ArcImage key={src} ref={image} src={src} alt="" fill sizes={size === "xl" ? "88px" : size === "lg" ? "48px" : size === "md" ? "36px" : "28px"} onLoad={event => { delete event.currentTarget.dataset.loading; }} onError={() => setFailedSrc(src)} /> : <span className={src ? styles.fallback : undefined} aria-hidden="true">{initials}</span>}
    <AnimatePresence initial={false}>{status && <motion.i key={status} className={[styles.status, styles[status]].join(" ")} aria-hidden="true" initial={{ opacity: 0, scale: .6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: .6, transition: { duration: reduceMotion ? 0 : motionTokens.duration.fast } }} transition={reduceMotion ? { duration: 0 } : motionTokens.spring.snappy} />}</AnimatePresence>
  </span>;
}

```
