Avatar
Interactive UI Explorer
아래에서 각 컴포넌트의 다양한 Variants와 States를 문서화된 형태로 테스트해볼 수 있습니다.
Live Preview
AL
BO
CA
+2
Avatar
이미지가 있으면 그대로, 없거나 로드 실패 시 이름의 이니셜로 자동 폴백. 이름 해시 기반 6색 팔레트로 안정적인 색상을 부여합니다.
Sizes
xs · sm · md · lg · xl 5단계.
XS
JD
Live Preview
SM
JD
Live Preview
MD
JD
Live Preview
LG
JD
Live Preview
XL
JD
Live Preview
With image / fallback
src 제공 시 이미지, 실패 시 이니셜로 폴백.
Image
Live Preview
Fallback (no src)
AW
Live Preview
Status dot
BO
Live Preview
AvatarGroup
여러 아바타를 겹쳐 표시. max 초과 시 +N 카운트 칩.
Source Code
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>
);
}