아바타 UI 9종 — 사진 파일 없이 만드는 사용자 표시
아바타 UI 는 사진 한 자리로 사람을 보여 주는 부품입니다. 여기 아홉 가지는 이름만으로 색과 무늬를 정하는 원, 사진이 없을 때 대체 순서, 겹침·상태 점·멘션 칩까지 사진 파일 한 장 없이 HTML·CSS·짧은 JS 로 짜 둔 것이라, 붙여 넣으면 그대로 서는 사용자 표시가
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 이니셜 자동 색 아바타
- 02 3단 대체 아바타
- 03 이름 무늬 아바타
- 04 모양·크기 세트
- 05 겹친 아바타와 +N
- 06 접속 상태 점
- 07 멘션 칩 입력
- 08 호버 사용자 카드
- 09 사진 자리 잡기
아홉 개를 놓은 순서는 화면에 등장하는 순서입니다. 먼저 아바타 스스로 채워지는 셋 — 이름에서 색을 얻는 원(01), 사진이 끝내 안 올 때의 3단 대체(02), 이니셜이 겹칠 때의 무늬(03)입니다. 다음 셋은 아바타가 디자인 시스템과 팀 안에 놓이는 방식 — 모양·크기 세트(04), 참여자를 접어 보여 주는 겹침(05), 접속 상태 점(06)입니다. 마지막 셋은 아바타가 입력과 확인에 끼어드는 자리 — 멘션 칩(07), 호버 사용자 카드(08), 사진 자리 잡기(09)입니다. 아홉 개는 잉크 #17141a · 크림 #fff7e6 · 여섯 톤(파랑 #2a5fd8 · 바이올렛 #5b4bd6 · 청록 #0d6e5c · 자주 #a1266f · 벽돌 · 돌색) 팔레트를 함께 쓰기에 두세 개를 한 화면에 섞어도 색이 싸우지 않습니다. 프로필 카드 전체의 겉모습이 궁금하면 프로필 카드 디자인 9종이 이웃이고, 로그인 화면 쪽 부품은 로그인 가입 카드 UI 9종에, 계정 보안 화면은 계정 보안 설정 UI 9종에 정리되어 있습니다.
01이니셜 자동 색 아바타
이름 글자를 숫자로 접어 여섯 색 중 하나를 고르는 원입니다. 해시가 순수 함수라 같은 이름은 화면을 다시 그릴 때마다 늘 같은 색을 받고, 입력칸에 이름을 치면 원과 이니셜이 그 자리에서 바뀝니다. 사진을 올리지 않은 사용자가 많은 목록·댓글에 씁니다.
// 이름을 숫자로 접어 0~5 — 순수 함수라 같은 이름은 늘 같은 색을 고른다
function hashName(text) {
var h = 0;
for (var i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) >>> 0;
return h % 6;
}
// 한국어면 첫 글자 1자, 라틴이면 앞 두 단어의 머리글자
function initialsOf(text) {
var trimmed = text.trim();
if (!trimmed) return '';
if (/[가-힣]/.test(trimmed[0])) return trimmed[0];
var words = trimmed.split(/\s+/).filter(function (w) { return /[A-Za-z]/.test(w[0] || ''); });
return words.slice(0, 2).map(function (w) { return w[0].toUpperCase(); }).join('');
}
// JS 는 색을 모른다 — data-tone 번호만 바꾸고 scss 가 여섯 색을 칠한다
function applyName() {
avatar.dataset.tone = String(hashName(input.value));
avatar.textContent = initialsOf(input.value);
}
.ai__avatar[data-tone="0"] { background: $av-blue; }
.ai__avatar[data-tone="1"] { background: $av-violet; }
.ai__avatar[data-tone="2"] { background: $av-teal; }
.ai__avatar[data-tone="3"] { background: $av-plum; }
.ai__avatar[data-tone="4"] { background: $av-brick; }
.ai__avatar[data-tone="5"] { background: $av-slate; }
023단 대체 아바타
사진을 기다리는 자리, 사진이 끝내 안 오면 이니셜, 이름조차 없으면 사람 도형까지 세 단계가 한 프레임에 끊어 바뀝니다. 외부에서 받아 온 사진 주소가 자주 깨지는 화면에 씁니다.
// 사진은 일부러 없는 파일을 가리킨다 — error 이벤트이 대체 단계를 밀어 올린다
photo.addEventListener('error', function () {
root.dataset.step = 'initials';
});
// 버튼 하나로 이니셜 ↔ 사람 도형을 오간다(이름 지우기/되돌리기)
btn.addEventListener('click', function () {
root.dataset.step = root.dataset.step === 'initials' ? 'mark' : 'initials';
});
// 세 단계를 steps(1, end) 로 한 프레임에 컷한다 — 절반 투명 상태가 겹치지 않는다.
// 0% 대기 / 34% 이니셜 / 67% 사람 도형, 88% 안에 대기로 복귀.
// 컷만 있으면 움직임 프레임이 모자라니 대기 단계의 시머 막대가 계속 지나가 프레임을 채운다
.fb.is-demo .fb__photo,
.fb.is-demo .fb__stepTxt--photo {
animation-name: fbCutPhoto;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes fbCutPhoto {
0% { opacity: 1; }
34% { opacity: 0; }
88% { opacity: 1; }
100% { opacity: 1; }
}
03이름 무늬 아바타
같은 이름에서 늘 같은 5×5 무늬가 나오는 아이덴티콘입니다. 해시 비트를 왼쪽 세 열에 배분한 뒤 거울처럼 맞춰 대칭으로 세우므로, 이니셜이 겹쳐 구분이 안 되는 팀·저장소 목록에서 사람을 가려 냅니다.
// FNV-1a 32비트 — 같은 이름은 늘 같은 정수를 돌려준다
function hash32(text) {
var h = 2166136261;
for (var i = 0; i < text.length; i++) {
h = (h ^ text.charCodeAt(i)) >>> 0;
h = Math.imul(h, 16777619) >>> 0;
}
return h >>> 0;
}
// 비트 15개를 열 0·1·2 에 놓고 열 0↔4 · 1↔3 을 거울처럼 맞춰 25칸을 채운다
function cellsOf(text) {
var h = hash32(text);
var cells = [];
for (var row = 0; row < 5; row++) {
var left = [(h >>> (row * 3)) & 1, (h >>> (row * 3 + 1)) & 1, (h >>> (row * 3 + 2)) & 1];
cells.push(left[0], left[1], left[2], left[1], left[0]);
}
return cells;
}
.id__grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
}
// 칸마다 시차 숨쉼 — 켜진 칸이 사라지지 않게 opacity 로 끄지 않는다
.id.is-demo .id__cell {
animation-name: idBreathe;
animation-duration: $duration; // 1s — 2s 의 약수
animation-delay: calc(var(--i) * 40ms);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
04모양·크기 세트
같은 아바타 하나가 원·모난 원·육각을 오가고 네 크기(28·36·48·64px)가 한 줄에 섞입니다. 글자 크기와 테두리 두께는 --size 하나에서 비율로 따라가므로, 여러 화면에서 같은 아바타를 재사용하는 디자인 시스템에 씁니다.
// 모양은 전부 clip-path 로 — 서로 다른 함수라 보간되지 않으니 교체는 steps 컷으로 점프한다
.as[data-shape="circle"] .as__av { clip-path: circle(50%); }
.as[data-shape="squircle"] .as__av { clip-path: inset(0 round 34%); }
.as[data-shape="hex"] .as__av { clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%); }
.as__av { aspect-ratio: 1; }
// --size 하나로 글자 크기·테두리 두께가 비율로 따라간다(px 하드코딩 금지)
.as__av {
width: var(--size);
font-size: calc(var(--size) * .4);
border: calc(var(--size) / 14) solid $av-deep;
}
05겹친 아바타와 +N
네 사람만 겹쳐 보이고 나머지는 +N 칸 하나로 접힙니다. 손이 닿거나 초점이 들어오면 겹침이 풀려 한 사람씩 옆으로 벌어지고, +N 을 누르면 접힌 인원이 목록으로 펼쳐집니다. 문서·과제에 참여한 사람을 한 줄로 보여 주는 곳에 씁니다.
// 겹침은 음수 margin — 간격은 4px 격자 토큰만 쓴다
.st__stack > * + * { margin-left: -#{$sp-3}; }
// 펼침은 겹침분만큼 되돌리는 translateX — 텔레포트 없이 왕복한다
@keyframes stSpread {
0% { transform: translateX(0); }
40% { transform: translateX(calc(var(--i) * #{$sp-3})); }
78% { transform: translateX(calc(var(--i) * #{$sp-3})); }
90% { transform: translateX(0); }
100% { transform: translateX(0); }
}
// 접힌 인원 목록 — display:flex 가 UA 의 hidden 을 이기므로 한 줄 더 얹는다
.st__row[hidden] { display: none; }
06접속 상태 점
아바타 테두리를 마스크로 동그랗게 파내고 그 구멍에 점을 앉힙니다. 점이 얼굴이나 가장자리를 덮지 않고 뚫린 자리에 정확히 들어가며, 상태가 바뀔 때마다 옆 글자와 읽어 주는 이름이 함께 바뀝니다. 지금 누가 접속해 있는지 보여 주는 팀 화면에 씁니다.
// 이 데모의 주제 — 아바타 면에 마스크로 구멍을 파고 점을 그 구멍에 앉힌다.
// 점이 얼굴이나 가장자리를 덮지 않고, 뚫린 자리에 정확히 들어간다
.pd__av {
-webkit-mask-image: radial-gradient(circle 11px at 82% 82%, transparent 98%, #000 100%);
mask-image: radial-gradient(circle 11px at 82% 82%, transparent 98%, #000 100%);
}
// 상태 점 — 마스크 구멍(82% 82%, 지름 약 21px)의 중심에 앉는 10px 원
.pd__dot {
position: absolute;
left: 82%;
top: 82%;
width: 10px;
height: 10px;
border-radius: 50%;
background: $av-teal;
transform: translate(-50%, -50%);
}
// aria-label 은 조사 앞뒤 공백이 생기지 않게 처음부터 완성된 문장으로 만든다
function applyLabel() {
wrap.setAttribute('aria-label', nameEl.textContent + ', ' + currentLabel());
}
07멘션 칩 입력
골뱅이(@)를 치면 아바타가 붙은 목록이 열리고 위아래 키로 짚어 고른 사람이 입력칸 앞에 칩으로 박힙니다. 칩은 지우기 버튼으로 다시 빠지고 Escape 로 목록이 닫힙니다. 댓글·과제에서 사람을 불러 지정하는 입력에 씁니다.
<input class="mc__input" type="text" role="combobox" aria-expanded="true"
aria-controls="mc-list" aria-autocomplete="list" aria-activedescendant="mc-opt-0"
placeholder="댓글을 입력하세요" autocomplete="off">
<ul class="mc__list" id="mc-list" role="listbox" aria-label="멘션할 사람">
<li class="mc__opt" id="mc-opt-0" role="option" aria-selected="true">
<span class="mc__av mc__av--blue" aria-hidden="true"></span><span class="mc__name">편집자</span>
</li>
</ul>
function setActive(i) {
var v = visible();
if (!v.length) return;
active = (i + v.length) % v.length;
v.forEach(function (o, k) { o.setAttribute('aria-selected', k === active ? 'true' : 'false'); });
input.setAttribute('aria-activedescendant', v[active].id);
}
08호버 사용자 카드
아바타에 손이 머문 뒤에야 작은 카드가 올라오고 손이 떠나도 잠시 기다렸다 닫힙니다. 카드 위로 마우스를 옮기는 동안 카드가 사라지지 않아 이름만 나온 자리에서 누구인지 확인할 수 있습니다.
// 열림은 300ms 지연 — 스치는 지나감에 카드가 흔들리지 않게.
// 닫힘은 200ms 유예 — 트리거에서 카드로 마우스가 건너가는 동안 카드가 살아 있다
trigger.addEventListener('pointerenter', function () {
clearTimeout(openT); clearTimeout(closeT);
openT = setTimeout(function () { card.hidden = false; }, 300);
});
function scheduleClose() {
clearTimeout(openT); clearTimeout(closeT);
closeT = setTimeout(function () { card.hidden = true; }, 200);
}
trigger.addEventListener('pointerleave', scheduleClose);
card.addEventListener('pointerenter', function () { clearTimeout(closeT); });
card.addEventListener('pointerleave', scheduleClose);
// 카드 등장은 컷 + 짧은 팝 — 반투명 유령 프레임이 생기지 않는다
@keyframes ucCard {
0% { opacity: 0; transform: translateY(6px); animation-timing-function: steps(1, end); }
26% { opacity: 1; transform: translateY(6px); animation-timing-function: $easing; }
34%, 100% { opacity: 1; transform: translateY(0); }
}
09사진 자리 잡기
둥근 창 아래에서 그림을 끌어 위치를 옮기고 막대로 배율을 키우는 자르기 화면입니다. 배율을 줄여도 창 바깥 여백이 생기지 않도록 이동 범위가 먼저 좁아듭니다. 프로필 사진을 올린 직후에 씁니다.
// 핵심 — 허용 이동 범위는 maxOffset = (사진크기 × 배율 − 창크기) / 2.
// 배율을 줄이면 범위가 먼저 좁아지므로 창 가장자리에 빈 여백이 절대 생기지 않는다.
// 사진 바탕은 창의 1.5배(inset -25%).
function clamp(v, zz) {
var w = win.offsetWidth; // 창 크기(렌더 변환과 무관)
var m = Math.max(0, (w * 1.5 * zz - w) / 2);
return Math.max(-m, Math.min(m, v));
}
function apply() {
root.style.setProperty('--x', x + 'px');
root.style.setProperty('--y', y + 'px');
root.style.setProperty('--z', '' + z);
}
// 변환은 이 한 곳에 모은다 — 드래그·배율·미리보기가 같은 변수를 공유한다
.cu__photo {
position: absolute;
inset: -25%;
transform: translate(var(--x, 0px), var(--y, 0px)) scale(var(--z, 1));
}
.cu__window { clip-path: circle(50%); }
어디서 깨지는가 — 함정
가장 먼저 넘침이 잡은 것은 07 의 멘션 목록이었습니다. 절대배치 목록에 top: 100% 를 걸었는데 그 100% 가 자리를 예약해 둔 뿌리 전체의 높이를 잡는 바람에, 목록이 예약된 자리 아래로 내려가 화면 300px 에 내용 332px 이 되었습니다. 레이아웃은 절대배치 높이를 부모 계산에서 빼므로 격자에서 스크롤바가 떠야 아는 병입니다. 목록의 기준을 줄(position: relative 이 붙은 입력 줄)로 옮기고 뿌리의 padding-bottom 은 열린 높이만큼 그대로 두니 목록이 예약된 자리 안에 들어와 넘침이 사라졌습니다.
두 번째는 09 의 가짜 사진이었습니다. 그라디언트 한 겹으로 그린 사진을 10px 남짓 움직였더니 움직임 측정에서 강도가 13.8 로 문턱(15)을 못 넘었습니다. 부드러운 그라디언트는 이웃 픽셀끼리 차이가 작아 눈에는 움직여도 픽셀 차이로는 거의 안 남는 것입니다. 밝은 얼굴 반점 하나와 어두운 어깨 반점 하나를 겹쳐 사진의 경계를 뚜렷하게 만들자 강도가 16.4 로 올라왔습니다. 같은 까닭으로 배경을 그라디언트로 채운 데모의 글자 대비는 주변 색을 다 계산해 봐야 합니다 — 미리 계산해 두지 않으면 어두운 색 위의 짙은 글자가 조용히 지워집니다.
세 번째는 마스크 접두어입니다. 06 이 쓰는 mask-image 는 2026-09-12 caniuse 실측으로 전역 지원 92.86% 에 부분 지원 4.17%, 접두어 없는 표준 속성은 크롬 120 · 사파리 15.4 · 파이어폭스 53 부터입니다. 다만 그 아래 버전이 남아 있는 동안은 -webkit-mask-image 를 함께 적는 것이 안전하여 두 줄을 나란히 두었습니다. 마지막으로 상태를 갈아 끼우는 02·06 은 처음에 서서히 옮기는 쪽으로 짰다가 두 상태가 반투명하게 겹친 프레임을 보고 steps(1, end) 컷으로 바꿨습니다 — 컷만 남기면 움직임 프레임이 모자라 시머 막대와 점의 맥동을 곁들여 프레임을 채웠습니다.
측정값은 이 네 군데를 그대로 보여 줍니다. 07 은 목록 전체가 움직여 누적면적 21.97% 로 아홉 중 가장 넓고, 08 은 카드 등장 순간의 밝기 차가 커 강도 125.5 로 가장 세며, 09 은 반점을 넣고도 16.4 로 아홉 중 가장 낮아 이 부품이 그라디언트 위에서 얼마나 조용한지를 보여 줍니다. 반대로 06 은 점 하나의 맥동이라 누적면적 0.56% 에 불과하지만 움직인 프레임은 17장이나 담겨, 작은 부품이 죽은 그림으로 보이지 않게 하는 건 면적이 아니라 프레임의 수임을 확인했습니다. 03 의 숨쉼은 칸 25개가 각자 시차를 받아 등록된 애니메이션만 100개이고 24프레임 가운데 23프레임에 움직임이 남습니다. 01 도 같은 23장을 채워 시차의 기준이 되고, 02 와 04 는 컷에 시머·팝을 곁들여 각각 11장·14장을 확보했습니다. 위의 군데군데를 다 고친 완성본 아홉 벌은 zip 으로 묶어 두었고, 압축을 푸는 비밀번호는 dchsanq2 입니다.
접근성
대비는 감이 아니라 WCAG 2.1 상대휘도 공식으로 한 조합 한 조합 계산해 통과한 것만 올렸습니다. 이니셜 글자는 여섯 톤 위에서 언제나 같은 크림 한 가지입니다.
| 앞 | 뒤 | 실측 대비 | 본문 기준 |
|---|---|---|---|
| 크림 #fff7e6 | 파랑 #2a5fd8 | 5.29:1 | 통과 |
| 크림 #fff7e6 | 바이올렛 #5b4bd6 | 5.76:1 | 통과 |
| 크림 #fff7e6 | 청록 #0d6e5c | 5.79:1 | 통과 |
| 크림 #fff7e6 | 자주 #a1266f | 6.52:1 | 통과 |
| 크림 #fff7e6 | 벽돌 #c2361a | 5.13:1 | 통과 |
| 크림 #fff7e6 | 돌색 #3b4a63 | 8.40:1 | 통과 |
| 크림 #fff7e6 | 잉크 #17141a | 17.11:1 | 통과 |
| 잉크 #17141a | 옐로 #ffd23f | 12.63:1 | 통과 |
| 잉크 #17141a | 오렌지 #ff4d1f | 5.50:1 | 통과 |
| 잉크 #17141a | 페이퍼 #ffffff | 18.24:1 | 통과 |
| 크림 #fff7e6 | 오렌지 #ff4d1f | 3.11:1 | 미달 — 쓰지 않는다 |
| 크림 #fff7e6 | 옐로 #ffd23f | 1.35:1 | 미달 — 쓰지 않는다 |
움직임을 덜어 달라는 설정(prefers-reduced-motion: reduce)에서는 아홉 개 모두 애니메이션만 꺼지고 이름·상태·값은 화면에 그대로 남습니다. 01 은 아바타 여섯이 다 올라온 채로 서고, 02 는 이니셜 단계에 멈추며, 03 은 무늬가 다 그어진 상태로 숨쉼만 멈춥니다. 04 는 마지막으로 고른 모양 그대로 크기 넷이 서 있고, 05 는 겹친 채로 있으며, 06 은 접속 상태의 색과 글자가 살아 있습니다. 07 은 목록이 열린 채 짚는 막대만 첫 줄에 멈추고, 08 은 카드가 열린 채 활동 줄이 읽히며, 09 는 사진이 가운데에 멈춰 창이 비지 않습니다.
구조 쪽에서 갖춘 것은 이렇습니다. 이름을 실명 대신 직무 명사(관리자·편집자·디자이너)로만 쓰고, 이니셜은 화면 문자열에서 JS 가 계산해 언어가 바뀌면 따라가게 했습니다. 01·03·09 는 이름을 치거나 끌어 보는 입력이 있고, 05 의 아바타는 전부 버튼이라 Tab 으로 사람을 하나씩 짚을 수 있으며, 06 은 상태마다 aria-label 을 "이름, 상태" 의 완성문으로 갈아 끼웁니다. 07 은 role="combobox" · aria-expanded · aria-activedescendant 로 짚는 줄을 읽어 주고, 08 은 aria-describedby 로 카드를 트리거에 묶은 뒤 지연과 유예는 사람 손에만 두었습니다. 마스크 접두어와 이음새의 근거는 MDN 의 mask-image 문서와 pointerenter 문서, 대비 기준은 W3C 의 최소 명도 대비 해설을 따랐습니다. 사용자 표시 소재를 더 둘러보려면 상태 표시 뱃지 모음으로, 이 사이트가 데모를 어떻게 렌더하고 재는지는 소개에 적어 두었습니다.
FAQ
아바타 크기는 몇 px 로 정하는 게 좋나요?
정해진 값보다 세트가 먼저입니다. 04 처럼 28·36·48·64px 네 단계로 두고 글자 크기와 테두리 두께를 calc(var(--size) * .4) 식으로 묶으면 어떤 크기를 골라도 비율이 유지됩니다. 화면이 좁아질 때 크기를 줄이는 대신 아래 줄을 덜 보여 주는 편이 대비를 지킵니다.
사진을 아직 안 올린 사용자는 어떻게 보여 주나요?
이름이 있으면 01 의 자동 색 이니셜이 기본값입니다. 사진 주소를 받아 오는 화면이라면 02 처럼 기다리는 시머 → 이니셜 → 사람 도형의 3단을 미리 정해 두고 img 의 error 이벤트에 다음 단계를 붙이면, 깨진 주소가 빈 알약으로 남지 않습니다.
접속 상태 점을 아바타에 그냥 겹쳐도 되나요?
겹치면 얼굴을 가리거나 가장자리 밖으로 밀려납니다. 06 처럼 아바타 면에 마스크로 구멍을 파고 점을 그 안에 앉히면 점이 늘 제자리에 있고, 상태가 바뀔 때 옆 글자와 aria-label 까지 함께 갈아 끼워 색만으로 정보가 끝나지 않게 합니다.