Avatar

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview
AlexAWBOCA
AL
BO
CA
+2

Avatar

이미지가 있으면 그대로, 없거나 로드 실패 시 이름의 이니셜로 자동 폴백. 이름 해시 기반 6색 팔레트로 안정적인 색상을 부여합니다.

Sizes

xs · sm · md · lg · xl 5단계.

XS
<Avatar name="John Doe" size="xs" />
JD
Live Preview
SM
<Avatar name="John Doe" size="sm" />
JD
Live Preview
MD
<Avatar name="John Doe" size="md" />
JD
Live Preview
LG
<Avatar name="John Doe" size="lg" />
JD
Live Preview
XL
<Avatar name="John Doe" size="xl" />
JD
Live Preview

With image / fallback

src 제공 시 이미지, 실패 시 이니셜로 폴백.

Image
<Avatar src="https://i.pravatar.cc/100?u=1" name="Alex" />
Alex
Live Preview
Fallback (no src)
<Avatar name="Alice Wong" />
AW
Live Preview
Status dot
<Avatar name="Bob" status="online" />
BO
Live Preview

AvatarGroup

여러 아바타를 겹쳐 표시. max 초과 시 +N 카운트 칩.

Source Code
<AvatarGroup max={3} size="md">
  <Avatar name="Alice" />
  <Avatar name="Bob" />
  <Avatar name="Carol" />
  <Avatar name="Dave" />
  <Avatar name="Eve" />
</AvatarGroup>
AL
BO
CA
+2
Live Preview
Implementation

제작 코드

이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.

Avatartypescript
'use client';

/**
 * Avatar — 사용자 아바타. 이미지 우선, 없거나 로드 실패 시 이름의 이니셜로 폴백.
 *
 * - sizes: xs / sm / md / lg / xl
 * - 자동 색상: name 해시 기반 6색 팔레트 (안정적)
 * - status dot (online/away/busy/offline) 옵션
 */

import { forwardRef, useState, type HTMLAttributes } from 'react';
import { cn } from '@/lib/cn';

export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export type AvatarStatus = 'online' | 'away' | 'busy' | 'offline';
export type AvatarShape = 'circle' | 'square';

export interface AvatarProps extends HTMLAttributes<HTMLSpanElement> {
  src?: string | null;
  /** 이름 — 이미지 fallback 시 이니셜 추출에 사용. alt에도 사용. */
  name?: string;
  size?: AvatarSize;
  shape?: AvatarShape;
  /** 우하단 상태 dot. */
  status?: AvatarStatus;
}

const sizeStyles: Record<AvatarSize, { box: string; text: string; status: string; statusOffset: string }> =
  {
    xs: { box: 'h-6 w-6', text: 'text-[10px]', status: 'h-1.5 w-1.5 ring-1', statusOffset: 'right-0 bottom-0' },
    sm: { box: 'h-8 w-8', text: 'text-xs', status: 'h-2 w-2 ring-2', statusOffset: 'right-0 bottom-0' },
    md: { box: 'h-10 w-10', text: 'text-sm', status: 'h-2.5 w-2.5 ring-2', statusOffset: 'right-0 bottom-0' },
    lg: { box: 'h-12 w-12', text: 'text-base', status: 'h-3 w-3 ring-2', statusOffset: 'right-0.5 bottom-0.5' },
    xl: { box: 'h-16 w-16', text: 'text-xl', status: 'h-3.5 w-3.5 ring-2', statusOffset: 'right-1 bottom-1' },
  };

const shapeStyles: Record<AvatarShape, string> = {
  circle: 'rounded-full',
  square: 'rounded-xl',
};

const statusColor: Record<AvatarStatus, string> = {
  online: 'bg-emerald-500',
  away: 'bg-amber-500',
  busy: 'bg-rose-500',
  offline: 'bg-slate-400',
};

const palette = [
  'bg-blue-100 text-blue-700',
  'bg-emerald-100 text-emerald-700',
  'bg-violet-100 text-violet-700',
  'bg-amber-100 text-amber-700',
  'bg-pink-100 text-pink-700',
  'bg-cyan-100 text-cyan-700',
];

function hashIndex(s: string, mod: number): number {
  let h = 0;
  for (let i = 0; i < s.length; i++) {
    h = (h * 31 + s.charCodeAt(i)) | 0;
  }
  return Math.abs(h) % mod;
}

function getInitials(name: string): string {
  const parts = name.trim().split(/\s+/).filter(Boolean);
  if (parts.length === 0) return '?';
  if (parts.length === 1) {
    const p = parts[0];
    return (p.length >= 2 ? p.slice(0, 2) : p).toUpperCase();
  }
  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}

export const Avatar = forwardRef<HTMLSpanElement, AvatarProps>(
  ({ src, name, size = 'md', shape = 'circle', status, className, ...rest }, ref) => {
    const [imgFailed, setImgFailed] = useState(false);
    const sz = sizeStyles[size];
    const showImage = Boolean(src) && !imgFailed;
    const initials = name ? getInitials(name) : '?';
    const colorClass = name ? palette[hashIndex(name, palette.length)] : 'bg-slate-200 text-slate-600';

    return (
      <span
        ref={ref}
        className={cn(
          'relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden font-bold uppercase ring-1 ring-slate-200/70',
          shapeStyles[shape],
          sz.box,
          showImage ? 'bg-slate-100' : colorClass,
          className,
        )}
        {...rest}
      >
        {showImage ? (
          // eslint-disable-next-line @next/next/no-img-element
          <img
            src={src ?? undefined}
            alt={name ?? ''}
            className="h-full w-full object-cover"
            onError={() => setImgFailed(true)}
            loading="lazy"
          />
        ) : (
          <span aria-label={name} className={cn('leading-none', sz.text)}>
            {initials}
          </span>
        )}

        {status && (
          <span
            aria-hidden
            className={cn(
              'absolute block rounded-full ring-white',
              sz.status,
              sz.statusOffset,
              statusColor[status],
            )}
          />
        )}
      </span>
    );
  },
);

Avatar.displayName = 'Avatar';

/* ============================================
  AvatarGroup — 여러 아바타를 겹쳐 표시
============================================ */

export interface AvatarGroupProps extends HTMLAttributes<HTMLDivElement> {
  /** 노출할 최대 개수. 초과 시 +N 카운트 칩 노출 (default: 3) */
  max?: number;
  /** 그룹 내 아바타 사이즈 (자식의 props.size를 강제로 덮어씀) */
  size?: AvatarSize;
  /** 자식 — Avatar 컴포넌트들 */
  children: React.ReactElement<AvatarProps> | React.ReactElement<AvatarProps>[];
}

const overlapBySize: Record<AvatarSize, string> = {
  xs: '-ml-1.5 first:ml-0',
  sm: '-ml-2 first:ml-0',
  md: '-ml-2.5 first:ml-0',
  lg: '-ml-3 first:ml-0',
  xl: '-ml-4 first:ml-0',
};

export function AvatarGroup({ max = 3, size = 'md', children, className, ...rest }: AvatarGroupProps) {
  const items = Array.isArray(children) ? children : [children];
  const visible = items.slice(0, max);
  const overflow = items.length - visible.length;
  const sz = sizeStyles[size];
  const overlap = overlapBySize[size];

  return (
    <div className={cn('inline-flex items-center', className)} {...rest}>
      {visible.map((child, i) => (
        <div key={i} className={cn('relative ring-2 ring-white rounded-full', overlap)}>
          {/* size 강제 적용 */}
          {{
            ...child,
            props: { ...child.props, size },
          } as typeof child}
        </div>
      ))}
      {overflow > 0 && (
        <div
          className={cn(
            'relative inline-flex items-center justify-center rounded-full bg-slate-100 text-slate-600 font-bold ring-2 ring-white',
            overlap,
            sz.box,
            sz.text,
          )}
          aria-label={`+${overflow} more`}
        >
          +{overflow}
        </div>
      )}
    </div>
  );
}