Avatar UI Design: 9 Parts, No Image Files
Avatar UI design is how one small circle makes a person recognizable. The nine parts draw it from a name alone — color, fallback, pattern, stack, presence —
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Auto-colored initial avatar
- 02 Three-step fallback avatar
- 03 Name-pattern avatar
- 04 Shape and size set
- 05 Overlapping stack with +N
- 06 Presence dot
- 07 Mention chip input
- 08 Hover user card
- 09 Photo positioning
The order follows the path an avatar takes as it fills itself in, from one circle to an avatar group ui design. The first three complete the avatar itself: the circle that picks its color from the name (01), the fallback chain for a photo that never arrives (02), and the identicon that tells people apart when initials collide (03). The next three seat it in a system and a team: the shape and size set (04), the overlapping stack with its +N slot (05), and the presence dot (06). The last three are where the avatar enters input and inspection: the mention chip (07), the hover user card (08), and the photo positioning step (09). All nine draw from one palette — ink #17141a, cream #fff7e6, and six tones (blue #2a5fd8, violet #5b4bd6, teal #0d6e5c, plum #a1266f, brick, slate) — so two or three of them can share a screen without the colors fighting. For the card the avatar sits inside, the neighbors are nine profile card design styles and login and signup card UI patterns; the account screen belongs to account security settings UI. The axis for the seat with no avatar to draw at all — the empty screen — is owned separately by empty state cards.
01Auto-colored initial avatar
What this user avatar ui design shows
The name folds into a number that picks one of six colors, so the same name lands in the same circle every time, and typing in the field swaps the circle and its letter on the spot. Nothing is stored — the hash is a pure function of the string.
<main class="stage">
<section class="ai is-demo" aria-label="Auto-colored initial avatar">
<div class="ai__field">
<label class="ai__label" for="ai-name">Name</label>
<input class="ai__input" id="ai-name" type="text" value="Admin" autocomplete="off" spellcheck="false" placeholder="Type a name">
<!-- 이니셜 글자와 data-tone 은 JS 가 입력값에서 계산해 넣는다(하드코딩 금지 — 언어팩이 바뀌면 따라간다) -->
<span class="ai__avatar" data-tone="0" aria-hidden="true"></span>
</div>
<p class="ai__hint">The same name always gets the same color</p>
<ul class="ai__row">
<li class="ai__item"><span class="ai__dot" data-tone="5" aria-hidden="true"></span><span class="ai__name">Editor</span></li>
<li class="ai__item"><span class="ai__dot" data-tone="0" aria-hidden="true"></span><span class="ai__name">Designer</span></li>
<li class="ai__item"><span class="ai__dot" data-tone="3" aria-hidden="true"></span><span class="ai__name">Translator</span></li>
<li class="ai__item"><span class="ai__dot" data-tone="1" aria-hidden="true"></span><span class="ai__name">Counselor</span></li>
<li class="ai__item"><span class="ai__dot" data-tone="4" aria-hidden="true"></span><span class="ai__name">Reviewer</span></li>
</ul>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 279 아바타 팔레트 — 이니셜 글자는 언제나 $subject-cream. 괄호는 크림 글자 기준 실측 대비비
$av-blue: #7c90be; // 5.29:1
$av-violet: #9791c9; // 5.76:1
$av-teal: #479082; // 5.79:1
$av-plum: #ab5488; // 6.52:1
$av-brick: $error; // 오류 토큰 — 5.13:1. 리터럴 금지 (BD10)
$av-slate: #3b4a63; // 8.40:1
$av-mist: #b6b0c0; // 잉크 무대 보조 글자 8.65:1
$av-deep: #4a4452; // 옐로 무대 보조 글자 6.49:1 · 페이퍼 무대 9.38:1
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 아래 줄 아바타가 한 번 솟았다 앉는 주기
$easing: $ease-spring;
$color: $av-mist; // 라벨·안내 줄 색
@include stage($stage-ink);
.ai {
display: grid;
justify-items: center;
gap: $sp-3;
width: min(400px, 92vw);
}
.ai__field {
display: flex;
align-items: center;
gap: $sp-3;
}
.ai__label {
color: $color;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
}
.ai__input {
width: 150px;
padding: $sp-2 $sp-3;
border: 2px solid $color;
border-radius: $r-sm;
background: transparent;
color: $subject-cream;
font-family: inherit;
font-weight: 600;
font-size: 14px;
line-height: 1.3;
}
.ai__input:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// 큰 아바타 — 색은 data-tone 번호가 고르고 scss 가 칠한다(JS 는 hex 를 모른다)
.ai__avatar {
display: grid;
place-items: center;
width: 68px;
height: 68px;
border-radius: 50%;
background: $av-slate;
color: $subject-cream; // 팔레트 여섯 색 위 크림 글자는 전부 4.5:1 이상 실측
font-weight: 700;
font-size: 26px;
line-height: 1;
}
.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; }
.ai__hint {
margin: 0;
color: $color;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
}
.ai__row {
display: flex;
gap: $sp-3;
margin: 0;
padding: 0;
list-style: none;
}
.ai__item { display: grid; justify-items: center; gap: $sp-1; }
.ai__dot {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: $av-slate;
color: $subject-cream;
font-weight: 700;
font-size: 17px;
line-height: 1;
}
.ai__dot[data-tone="0"] { background: $av-blue; }
.ai__dot[data-tone="1"] { background: $av-violet; }
.ai__dot[data-tone="2"] { background: $av-teal; }
.ai__dot[data-tone="3"] { background: $av-plum; }
.ai__dot[data-tone="4"] { background: $av-brick; }
.ai__dot[data-tone="5"] { background: $av-slate; }
.ai__name {
color: $color;
font-weight: 500;
font-size: 11px;
line-height: 1.2;
}
// ── 격자 미리보기 루프 ────────────────────────────────
// 아래 줄 아바타 다섯 개가 translateY + scale 로 차례로 솟았다 앉는다.
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로만 쓴다
.ai.is-demo .ai__dot {
animation-name: aiRise;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards; // 지연 구간에도 0% 자세(앉은 상태)가 찍힌다
}
.ai.is-demo .ai__item:nth-child(2) .ai__dot { animation-delay: 140ms; }
.ai.is-demo .ai__item:nth-child(3) .ai__dot { animation-delay: 280ms; }
.ai.is-demo .ai__item:nth-child(4) .ai__dot { animation-delay: 420ms; }
.ai.is-demo .ai__item:nth-child(5) .ai__dot { animation-delay: 560ms; }
// 한 바퀴를 78% 안에 끝낸 뒤 제자리에 머문다 — 마지막 장은 95.8% 지점이라 그 뒤는 안 찍힌다
@keyframes aiRise {
0% { transform: translateY(6px) scale(.94); }
36% { transform: translateY(-8px) scale(1.06); }
78% { transform: translateY(0) scale(1); }
100% { transform: translateY(0) scale(1); }
}
// 미리보기 루프 동안에는 자동완성 흔적 글자가 겹쳐 보이지 않게 한다
.ai.is-demo .ai__input::placeholder { color: transparent; }
// 좁은 화면 — 표시와 여백만 줄인다. 값을 올리지 않는다.
@media (max-width: 340px), (max-height: 220px) {
.ai { gap: $sp-2; }
.ai__field { gap: $sp-2; }
.ai__input { width: 120px; font-size: 13px; }
.ai__avatar { width: 56px; height: 56px; font-size: 22px; }
.ai__dot { width: 36px; height: 36px; font-size: 14px; }
.ai__row { gap: $sp-2; }
.ai__hint { font-size: 10px; }
.ai__name { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
// .ai.is-demo .ai__dot (0,3,0) 를 이기려면 !important 가 필요하다.
// 움직임만 끄고 아바타·글자는 그대로 남긴다
.ai__dot { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ai {
display: grid;
justify-items: center;
gap: 12px;
width: min(400px, 92vw);
}
.ai__field {
display: flex;
align-items: center;
gap: 12px;
}
.ai__label {
color: #b6b0c0;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
}
.ai__input {
width: 150px;
padding: 8px 12px;
border: 2px solid #b6b0c0;
border-radius: 8px;
background: transparent;
color: #fff7e6;
font-family: inherit;
font-weight: 600;
font-size: 14px;
line-height: 1.3;
}
.ai__input:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.ai__avatar {
display: grid;
place-items: center;
width: 68px;
height: 68px;
border-radius: 50%;
background: #3b4a63;
color: #fff7e6;
font-weight: 700;
font-size: 26px;
line-height: 1;
}
.ai__avatar[data-tone="0"] {
background: #7c90be;
}
.ai__avatar[data-tone="1"] {
background: #9791c9;
}
.ai__avatar[data-tone="2"] {
background: #479082;
}
.ai__avatar[data-tone="3"] {
background: #ab5488;
}
.ai__avatar[data-tone="4"] {
background: #c2361a;
}
.ai__avatar[data-tone="5"] {
background: #3b4a63;
}
.ai__hint {
margin: 0;
color: #b6b0c0;
font-weight: 500;
font-size: 11px;
line-height: 1.3;
}
.ai__row {
display: flex;
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.ai__item {
display: grid;
justify-items: center;
gap: 4px;
}
.ai__dot {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: #3b4a63;
color: #fff7e6;
font-weight: 700;
font-size: 17px;
line-height: 1;
}
.ai__dot[data-tone="0"] {
background: #7c90be;
}
.ai__dot[data-tone="1"] {
background: #9791c9;
}
.ai__dot[data-tone="2"] {
background: #479082;
}
.ai__dot[data-tone="3"] {
background: #ab5488;
}
.ai__dot[data-tone="4"] {
background: #c2361a;
}
.ai__dot[data-tone="5"] {
background: #3b4a63;
}
.ai__name {
color: #b6b0c0;
font-weight: 500;
font-size: 11px;
line-height: 1.2;
}
.ai.is-demo .ai__dot {
animation-name: aiRise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ai.is-demo .ai__item:nth-child(2) .ai__dot {
animation-delay: 140ms;
}
.ai.is-demo .ai__item:nth-child(3) .ai__dot {
animation-delay: 280ms;
}
.ai.is-demo .ai__item:nth-child(4) .ai__dot {
animation-delay: 420ms;
}
.ai.is-demo .ai__item:nth-child(5) .ai__dot {
animation-delay: 560ms;
}
@keyframes aiRise {
0% {
transform: translateY(6px) scale(0.94);
}
36% {
transform: translateY(-8px) scale(1.06);
}
78% {
transform: translateY(0) scale(1);
}
100% {
transform: translateY(0) scale(1);
}
}
.ai.is-demo .ai__input::placeholder {
color: transparent;
}
@media (max-width: 340px), (max-height: 220px) {
.ai {
gap: 8px;
}
.ai__field {
gap: 8px;
}
.ai__input {
width: 120px;
font-size: 13px;
}
.ai__avatar {
width: 56px;
height: 56px;
font-size: 22px;
}
.ai__dot {
width: 36px;
height: 36px;
font-size: 14px;
}
.ai__row {
gap: 8px;
}
.ai__hint {
font-size: 10px;
}
.ai__name {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ai__dot {
animation: none !important;
}
}(function () {
var root = document.querySelector('.ai');
var input = root.querySelector('.ai__input');
var avatar = root.querySelector('.ai__avatar');
// 이름을 숫자로 접어 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);
}
// 아래 줄 다섯 개도 Name 표시에서 이니셜과 색을 계산해 넣는다
var items = root.querySelectorAll('.ai__item');
Array.prototype.forEach.call(items, function (item) {
var name = item.querySelector('.ai__name').textContent;
var dot = item.querySelector('.ai__dot');
dot.dataset.tone = String(hashName(name));
dot.textContent = initialsOf(name);
});
input.addEventListener('input', applyName);
applyName();
// 사람이 손을 대면 미리보기 루프를 뗀다
function stopLoop() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./AvatarInitialAutoColor.module.scss";
export type AvatarInitialAutoColorProps = {
label?: string;
nameLabel?: string;
placeholder?: string;
hint?: string;
name?: string;
rowNames?: string[];
/** 아래 줄 아바타가 한 번 솟았다 앉는 주기(ms) */
duration?: number;
easing?: string;
/** 라벨·안내 줄 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 타입에 React 이벤트 제네릭이 없어 로컬 타입을 쓴다
type InputEvt = { target: { value: string } };
// 이름을 숫자로 접어 0~5 — 순수 함수라 같은 이름은 늘 같은 색을 고른다
function hashName(text: string): number {
let h = 0;
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) >>> 0;
return h % 6;
}
// 한국어면 첫 글자 1자, 라틴이면 앞 두 단어의 머리글자
function initialsOf(text: string): string {
const trimmed = text.trim();
if (!trimmed) return "";
if (/[가-힣]/.test(trimmed[0])) return trimmed[0];
const words = trimmed.split(/\s+/).filter((w) => /[A-Za-z]/.test(w[0] || ""));
return words.slice(0, 2).map((w) => w[0].toUpperCase()).join("");
}
export default function AvatarInitialAutoColor({
label = "이니셜 자동 색 아바타",
nameLabel = "이름",
placeholder = "이름을 입력하세요",
hint = "같은 이름은 늘 같은 색입니다",
name = "관리자",
rowNames = ["편집자", "디자이너", "번역가", "상담원", "검토자"],
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b6b0c0",
}: AvatarInitialAutoColorProps): ReactNode {
const [value, setValue] = useState<string>(name);
const [loop, setLoop] = useState<boolean>(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section
className={loop ? `${styles.ai} ${styles.isDemo}` : styles.ai}
style={vars}
aria-label={label}
onPointerDown={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<div className={styles.field}>
<label className={styles.label} htmlFor="ai-name">{nameLabel}</label>
<input
className={styles.input}
id="ai-name"
type="text"
value={value}
autoComplete="off"
spellCheck={false}
placeholder={placeholder}
onChange={(e: InputEvt) => setValue(e.target.value)}
/>
{/* 이니셜 글자와 data-tone 은 이름에서 계산해 넣는다 — JS 는 색을 모른다 */}
<span className={styles.avatar} data-tone={String(hashName(value))} aria-hidden="true">
{initialsOf(value)}
</span>
</div>
<p className={styles.hint}>{hint}</p>
<ul className={styles.row}>
{rowNames.map((rowName) => (
<li key={rowName} className={styles.item}>
<span className={styles.dot} data-tone={String(hashName(rowName))} aria-hidden="true">
{initialsOf(rowName)}
</span>
<span className={styles.name}>{rowName}</span>
</li>
))}
</ul>
</section>
);
}01 · Auto-colored initial avatar — Measured area 6.244% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Use it in lists and comments where most people never upload a photo.
// Fold the name into a number 0-5 — a pure function, so the same name always picks the same color
function hashName(text) {
var h = 0;
for (var i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) >>> 0;
return h % 6;
}
// Korean names take the first syllable, Latin names the first letters of the first two words
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 never learns a hex — it flips the data-tone number and scss paints one of six colors
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; }
02Three-step fallback avatar
What happens on screen
A slot waits for the photo, initials take over when the photo never arrives, and a person mark appears when even the name is missing — three steps that cut over in a single frame. The photo element deliberately points at a missing file so the error event drives the chain downward.
<main class="stage">
<section class="fb is-demo" aria-label="Three-step fallback avatar" data-step="photo">
<p class="fb__name">Admin</p>
<!-- 세 겹이 한 자리에 겹쳐 앉는다: 사진 자리(로딩 시머) · 이니셜 · 사람 도형.
data-step 이 어느 겹을 보일지 정한다 -->
<span class="fb__slot">
<span class="fb__shine" aria-hidden="true"></span>
<img class="fb__photo" src="member-photo.jpg" alt="">
<span class="fb__initial" aria-hidden="true"></span>
<span class="fb__mark" aria-hidden="true">
<svg viewBox="0 0 256 256" width="34" height="34" aria-hidden="true" focusable="false" fill="currentColor"><path d="M192,96a64,64,0,1,1-64-64A64,64,0,0,1,192,96Z" opacity="0.2"/><path d="M230.92,212c-15.23-26.33-38.7-45.21-66.09-54.16a72,72,0,1,0-73.66,0C63.78,166.78,40.31,185.66,25.08,212a8,8,0,1,0,13.85,8c18.84-32.56,52.14-52,89.07-52s70.23,19.44,89.07,52a8,8,0,1,0,13.85-8ZM72,96a56,56,0,1,1,56,56A56.06,56.06,0,0,1,72,96Z"/></svg>
</span>
</span>
<p class="fb__step">
<span class="fb__stepTxt fb__stepTxt--photo">Step 1 · waiting for photo</span>
<span class="fb__stepTxt fb__stepTxt--initials">Step 2 · initials fallback</span>
<span class="fb__stepTxt fb__stepTxt--mark">Step 3 · person-mark fallback</span>
</p>
<button class="fb__btn" type="button">
<span class="fb__btnTxt fb__btnTxt--mark">Show person mark</span>
<span class="fb__btnTxt fb__btnTxt--initials">Back to initials</span>
</button>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 279 아바타 팔레트 — 이니셜 글자는 언제나 $subject-cream. 괄호는 크림 글자 기준 실측 대비비
$av-blue: #7c90be; // 5.29:1
$av-violet: #9791c9; // 5.76:1
$av-teal: #479082; // 5.79:1
$av-plum: #ab5488; // 6.52:1
$av-brick: $error; // 오류 토큰 — 5.13:1. 리터럴 금지 (BD10)
$av-slate: #3b4a63; // 8.40:1
$av-mist: #b6b0c0; // 잉크 무대 보조 글자 8.65:1
$av-deep: #4a4452; // 옐로 무대 보조 글자 6.49:1 · 페이퍼 무대 9.38:1
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 세 단계 컷이 도는 한 바퀴
$easing: $ease-out; // 시머 막대가 지나가는 결
$color: $stage-ink; // 옐로 무대 글자 12.63:1 — 크림은 1.35:1 로 금지
@include stage($stage-yellow);
.fb {
display: grid;
justify-items: center;
gap: $sp-3;
width: min(320px, 92vw);
}
.fb__name {
margin: 0;
color: $color;
font-weight: 700;
font-size: 14px;
line-height: 1.2;
}
// 세 겹이 겹쳐 앉는 자리 — 사진(안 오면 시머) · 이니셜 · 사람 도형
.fb__slot {
position: relative;
display: grid;
place-items: center;
width: 68px;
height: 68px;
border-radius: 50%;
background: $stage-paper;
overflow: hidden;
}
.fb__photo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover; // 칩 — 어떤 비율의 사진이 와도 원을 꽉 채운다
opacity: 0;
}
// 로딩 시머 막대 — 대기 단계에서 계속 지나가며 움직임 프레임을 채운다
.fb__shine {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 34px;
background: rgba(23, 20, 26, .12);
animation-name: fbShine;
animation-duration: 1s;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.fb__initial {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: $stage-paper;
color: $color; // 옐로 무대 글자는 잉크로
font-weight: 700;
font-size: 26px;
line-height: 1;
opacity: 0;
}
.fb__mark {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: $stage-paper;
color: $color;
opacity: 0;
}
.fb__mark svg { width: 34px; height: 34px; }
// 정적 표시 — data-step 이 보일 겹 하나만 켠다 (.is-demo 를 뗀 화면이 비면 안 된다)
.fb[data-step="photo"] .fb__photo { opacity: 1; }
.fb[data-step="initials"] .fb__initial { opacity: 1; }
.fb[data-step="mark"] .fb__mark { opacity: 1; }
// 단계 한 줄 — 세 문장이 한 칸에 겹쳐 있고 컷 타이밍에 맞춰 바뀐다
.fb__step {
display: grid;
justify-items: center;
margin: 0;
}
.fb__stepTxt {
grid-area: 1 / 1;
color: $av-deep; // 옐로 무대 보조 글자 6.49:1
font-weight: 600;
font-size: 12px;
line-height: 1.3;
opacity: 0;
}
.fb[data-step="photo"] .fb__stepTxt--photo,
.fb[data-step="initials"] .fb__stepTxt--initials,
.fb[data-step="mark"] .fb__stepTxt--mark { opacity: 1; }
.fb__btn {
display: grid;
margin: 0;
padding: $sp-2 $sp-5;
border: 0;
border-radius: $r-control;
background: $color;
color: $stage-yellow;
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1.2;
cursor: pointer;
}
.fb__btn:focus-visible { outline: 2px solid $color; outline-offset: 3px; }
.fb__btnTxt { grid-area: 1 / 1; visibility: hidden; }
.fb[data-step="initials"] .fb__btnTxt--mark,
.fb[data-step="mark"] .fb__btnTxt--initials { visibility: visible; }
// ── 격자 미리보기 루프 ────────────────────────────────
// 세 단계를 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;
}
.fb.is-demo .fb__initial,
.fb.is-demo .fb__stepTxt--initials {
animation-name: fbCutInitial;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fb.is-demo .fb__mark,
.fb.is-demo .fb__stepTxt--mark {
animation-name: fbCutMark;
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; }
}
@keyframes fbCutInitial {
0% { opacity: 0; }
34% { opacity: 1; }
67% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes fbCutMark {
0% { opacity: 0; }
67% { opacity: 1; }
88% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes fbShine {
0% { transform: translateX(-40px); }
100% { transform: translateX(80px); }
}
// 좁은 화면 — 표시와 여백만 줄인다. 값을 올리지 않는다.
@media (max-width: 340px), (max-height: 220px) {
.fb { gap: $sp-2; }
.fb__slot { width: 56px; height: 56px; }
.fb__initial { font-size: 22px; }
.fb__mark svg { width: 28px; height: 28px; }
.fb__name { font-size: 12px; }
.fb__stepTxt { font-size: 11px; }
.fb__btn { padding: $sp-2 $sp-4; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
// .fb.is-demo … (0,3,0) 를 이기려면 !important 가 필요하다.
// 컷과 시머만 끄고, 정적 규칙(data-step) 이 보일 겹을 이어받는다
.fb__photo, .fb__initial, .fb__mark, .fb__stepTxt, .fb__shine { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fb {
display: grid;
justify-items: center;
gap: 12px;
width: min(320px, 92vw);
}
.fb__name {
margin: 0;
color: #17141a;
font-weight: 700;
font-size: 14px;
line-height: 1.2;
}
.fb__slot {
position: relative;
display: grid;
place-items: center;
width: 68px;
height: 68px;
border-radius: 50%;
background: #ffffff;
overflow: hidden;
}
.fb__photo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
}
.fb__shine {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 34px;
background: rgba(23, 20, 26, 0.12);
animation-name: fbShine;
animation-duration: 1s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.fb__initial {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: #ffffff;
color: #17141a;
font-weight: 700;
font-size: 26px;
line-height: 1;
opacity: 0;
}
.fb__mark {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: #ffffff;
color: #17141a;
opacity: 0;
}
.fb__mark svg {
width: 34px;
height: 34px;
}
.fb[data-step=photo] .fb__photo {
opacity: 1;
}
.fb[data-step=initials] .fb__initial {
opacity: 1;
}
.fb[data-step=mark] .fb__mark {
opacity: 1;
}
.fb__step {
display: grid;
justify-items: center;
margin: 0;
}
.fb__stepTxt {
grid-area: 1/1;
color: #4a4452;
font-weight: 600;
font-size: 12px;
line-height: 1.3;
opacity: 0;
}
.fb[data-step=photo] .fb__stepTxt--photo,
.fb[data-step=initials] .fb__stepTxt--initials,
.fb[data-step=mark] .fb__stepTxt--mark {
opacity: 1;
}
.fb__btn {
display: grid;
margin: 0;
padding: 8px 20px;
border: 0;
border-radius: 12px;
background: #17141a;
color: #ffd23f;
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1.2;
cursor: pointer;
}
.fb__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.fb__btnTxt {
grid-area: 1/1;
visibility: hidden;
}
.fb[data-step=initials] .fb__btnTxt--mark,
.fb[data-step=mark] .fb__btnTxt--initials {
visibility: visible;
}
.fb.is-demo .fb__photo,
.fb.is-demo .fb__stepTxt--photo {
animation-name: fbCutPhoto;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fb.is-demo .fb__initial,
.fb.is-demo .fb__stepTxt--initials {
animation-name: fbCutInitial;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fb.is-demo .fb__mark,
.fb.is-demo .fb__stepTxt--mark {
animation-name: fbCutMark;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes fbCutPhoto {
0% {
opacity: 1;
}
34% {
opacity: 0;
}
88% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes fbCutInitial {
0% {
opacity: 0;
}
34% {
opacity: 1;
}
67% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes fbCutMark {
0% {
opacity: 0;
}
67% {
opacity: 1;
}
88% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes fbShine {
0% {
transform: translateX(-40px);
}
100% {
transform: translateX(80px);
}
}
@media (max-width: 340px), (max-height: 220px) {
.fb {
gap: 8px;
}
.fb__slot {
width: 56px;
height: 56px;
}
.fb__initial {
font-size: 22px;
}
.fb__mark svg {
width: 28px;
height: 28px;
}
.fb__name {
font-size: 12px;
}
.fb__stepTxt {
font-size: 11px;
}
.fb__btn {
padding: 8px 16px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.fb__photo, .fb__initial, .fb__mark, .fb__stepTxt, .fb__shine {
animation: none !important;
}
}(function () {
var root = document.querySelector('.fb');
var img = root.querySelector('.fb__photo');
var btn = root.querySelector('.fb__btn');
var nameEl = root.querySelector('.fb__name');
var initialEl = root.querySelector('.fb__initial');
// 이니셜은 Name 표시에서 계산한다(한국어 첫 글자 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('');
}
initialEl.textContent = initialsOf(nameEl.textContent);
// 사진 파일은 일부러 없다(404) — error 이벤트에서 스스로 이니셜 단계로 내려간다
img.addEventListener('error', function () {
if (root.dataset.step === 'photo') root.dataset.step = 'initials';
});
if (img.complete && img.naturalWidth === 0 && root.dataset.step === 'photo') {
root.dataset.step = 'initials';
}
// 버튼 하나가 이니셜과 사람 도형을 오간다 (키보드 Enter·Space 로도 눌린다)
btn.addEventListener('click', function () {
root.dataset.step = (root.dataset.step === 'mark') ? 'initials' : 'mark';
});
// 사람이 손을 대면 미리보기 루프를 뗀다
function stopLoop() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./AvatarFallbackChain.module.scss";
export type AvatarFallbackChainProps = {
label?: string;
name?: string;
stepPhoto?: string;
stepInitials?: string;
stepMark?: string;
btnToMark?: string;
btnToInitials?: string;
/** 세 단계 컷이 도는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 옐로 무대 글자색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// 이니셜은 이름에서 계산한다(한국어 첫 글자 1자 · 라틴 앞 두 단어 머리글자)
function initialsOf(text: string): string {
const trimmed = text.trim();
if (!trimmed) return "";
if (/[가-힣]/.test(trimmed[0])) return trimmed[0];
const words = trimmed.split(/\s+/).filter((w) => /[A-Za-z]/.test(w[0] || ""));
return words.slice(0, 2).map((w) => w[0].toUpperCase()).join("");
}
export default function AvatarFallbackChain({
label = "3단 대체 아바타",
name = "관리자",
stepPhoto = "1단계 · 사진 기다리는 중",
stepInitials = "2단계 · 이니셜 대체",
stepMark = "3단계 · 사람 도형 대체",
btnToMark = "사람 도형으로",
btnToInitials = "이니셜로 되돌리기",
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: AvatarFallbackChainProps): ReactNode {
const [step, setStep] = useState<"photo" | "initials" | "mark">("photo");
const [loop, setLoop] = useState<boolean>(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section
className={loop ? `${styles.fb} ${styles.isDemo}` : styles.fb}
style={vars}
aria-label={label}
data-step={step}
onPointerDown={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<p className={styles.name}>{name}</p>
{/* 세 겹이 한 자리에 겹쳐 앉는다: 사진 자리(로딩 시머) · 이니셜 · 사람 도형.
data-step 이 어느 겹을 보일지 정한다 */}
<span className={styles.slot}>
<span className={styles.shine} aria-hidden="true" />
{/* 사진 파일은 일부러 없다(404) — error 에서 스스로 이니셜 단계로 내려간다 */}
<img
className={styles.photo}
src="member-photo.jpg"
alt=""
onError={() => setStep((s) => (s === "photo" ? "initials" : s))}
/>
<span className={styles.initial} aria-hidden="true">{initialsOf(name)}</span>
<span className={styles.mark} aria-hidden="true">
<svg viewBox="0 0 256 256" width="34" height="34" aria-hidden="true" focusable="false" fill="currentColor"><path d="M192,96a64,64,0,1,1-64-64A64,64,0,0,1,192,96Z" opacity="0.2" /><path d="M230.92,212c-15.23-26.33-38.7-45.21-66.09-54.16a72,72,0,1,0-73.66,0C63.78,166.78,40.31,185.66,25.08,212a8,8,0,1,0,13.85,8c18.84-32.56,52.14-52,89.07-52s70.23,19.44,89.07,52a8,8,0,1,0,13.85-8ZM72,96a56,56,0,1,1,56,56A56.06,56.06,0,0,1,72,96Z" /></svg>
</span>
</span>
<p className={styles.step}>
<span className={`${styles.stepTxt} ${styles.stepTxtPhoto}`}>{stepPhoto}</span>
<span className={`${styles.stepTxt} ${styles.stepTxtInitials}`}>{stepInitials}</span>
<span className={`${styles.stepTxt} ${styles.stepTxtMark}`}>{stepMark}</span>
</p>
{/* 버튼 하나가 이니셜과 사람 도형을 오간다 (키보드 Enter·Space 로도 눌린다) */}
<button
className={styles.btn}
type="button"
onClick={() => setStep((s) => (s === "mark" ? "initials" : "mark"))}
>
<span className={`${styles.btnTxt} ${styles.btnTxtMark}`}>{btnToMark}</span>
<span className={`${styles.btnTxt} ${styles.btnTxtInitials}`}>{btnToInitials}</span>
</button>
</section>
);
}02 · Three-step fallback avatar — Measured area 3.383% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Use it on screens where externally supplied photo URLs break often.
// The photo deliberately points at a missing file — the error event pushes the fallback step up
img.addEventListener('error', function () {
if (root.dataset.step === 'photo') root.dataset.step = 'initials';
});
if (img.complete && img.naturalWidth === 0 && root.dataset.step === 'photo') {
root.dataset.step = 'initials';
}
// One button walks between initials and the person mark (Enter and Space press it too)
btn.addEventListener('click', function () {
root.dataset.step = (root.dataset.step === 'mark') ? 'initials' : 'mark';
});
// The three steps cut over in a single frame with steps(1, end) — no half-transparent state overlaps.
// 0% waiting / 34% initials / 67% person mark, back to waiting inside 88%.
// Cuts alone would starve the frame count, so the waiting step keeps a shimmer bar sweeping through
.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; }
}
03Name-pattern avatar
What moves
Hash bits spread across the cells so the same name always yields the same 5x5 pattern, with the left three columns mirrored onto the right so the mark is symmetrical. Each of the 25 cells breathes on its own staggered delay, never switching off through opacity.
<main class="stage">
<section class="id is-demo" aria-label="Name-pattern avatar">
<div class="id__field">
<label class="id__label" for="id-name">Name</label>
<input class="id__input" id="id-name" type="text" value="Admin" autocomplete="off" spellcheck="false" placeholder="Type a name">
</div>
<!-- 25칸은 JS 가 만든다: 해시 비트를 15칸(열 0·1·2)에 배분하고 열 0↔4 · 1↔3 을 거울처럼 맞춘다 -->
<div class="id__grid" data-tone="1" aria-hidden="true"></div>
<ul class="id__list">
<li class="id__item"><div class="id__mini" data-tone="5" aria-hidden="true"></div><span class="id__name">Designer</span></li>
<li class="id__item"><div class="id__mini" data-tone="4" aria-hidden="true"></div><span class="id__name">Planner</span></li>
<li class="id__item"><div class="id__mini" data-tone="0" aria-hidden="true"></div><span class="id__name">Operator</span></li>
</ul>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 279 아바타 팔레트 — 이니셜 글자는 언제나 $subject-cream. 괄호는 크림 글자 기준 실측 대비비
$av-blue: #7c90be; // 5.29:1
$av-violet: #9791c9; // 5.76:1
$av-teal: #479082; // 5.79:1
$av-plum: #ab5488; // 6.52:1
$av-brick: $error; // 오류 토큰 — 5.13:1. 리터럴 금지 (BD10)
$av-slate: #3b4a63; // 8.40:1
$av-mist: #b6b0c0; // 잉크 무대 보조 글자 8.65:1
$av-deep: #4a4452; // 옐로 무대 보조 글자 6.49:1 · 페이퍼 무대 9.38:1
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s; // 칸 숨쉬기 한 바퀴 (2s 의 약수)
$easing: $ease-out;
$color: $stage-ink; // 오렌지 무대 글자 5.50:1 — 크림은 3.11:1 로 금지
@include stage($stage-orange);
.id {
display: grid;
justify-items: center;
gap: $sp-3;
width: min(360px, 92vw);
}
.id__field {
display: flex;
align-items: center;
gap: $sp-2;
}
.id__label {
color: $color;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
}
.id__input {
width: 150px;
padding: $sp-2 $sp-3;
border: 2px solid $color;
border-radius: $r-sm;
background: transparent;
color: $color;
font-family: inherit;
font-weight: 600;
font-size: 14px;
line-height: 1.3;
}
.id__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 큰 무늬 — 5×5. 칸 폭이 정확히 균등해진다
.id__grid {
display: grid;
grid-template-columns: repeat(5, 1fr); // 칩
width: 64px;
height: 64px;
}
// 칸은 켜진 것만 색이 들어간다. 꺼진 칸은 무대 위 희미한 격자로 자리만 지킨다
.id__cell {
transform-origin: center; // 칩 — 제자리에서 숨쉰다
border-radius: $r-xs;
background: rgba(23, 20, 26, .12);
}
.id__grid[data-tone="0"] .id__cell.is-on,
.id__mini[data-tone="0"] .id__cell.is-on { background: $av-blue; }
.id__grid[data-tone="1"] .id__cell.is-on,
.id__mini[data-tone="1"] .id__cell.is-on { background: $av-violet; }
.id__grid[data-tone="2"] .id__cell.is-on,
.id__mini[data-tone="2"] .id__cell.is-on { background: $av-teal; }
.id__grid[data-tone="3"] .id__cell.is-on,
.id__mini[data-tone="3"] .id__cell.is-on { background: $av-plum; }
.id__grid[data-tone="4"] .id__cell.is-on,
.id__mini[data-tone="4"] .id__cell.is-on { background: $av-brick; }
.id__grid[data-tone="5"] .id__cell.is-on,
.id__mini[data-tone="5"] .id__cell.is-on { background: $av-slate; }
.id__list {
display: flex;
gap: $sp-4;
margin: 0;
padding: 0;
list-style: none;
}
.id__item { display: grid; justify-items: center; gap: $sp-1; }
.id__mini {
display: grid;
grid-template-columns: repeat(5, 1fr);
width: 40px;
height: 40px;
}
.id__name {
color: $color;
font-weight: 500;
font-size: 11px;
line-height: 1.2;
}
// ── 격자 미리보기 루프 ────────────────────────────────
// 칸마다 시차를 두고 조용히 숨쉰다. 켜진 칸은 사라지지 않는다 — opacity 로 끄지 않는다(유령 프레임 금지).
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로만 쓴다
.id.is-demo .id__cell {
animation-name: idBreathe;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * 40ms);
animation-fill-mode: backwards; // 지연 구간에도 0% 자세(scale .72)가 찍힌다
}
@keyframes idBreathe {
0% { transform: scale(.72); }
50% { transform: scale(1); }
100% { transform: scale(.72); }
}
// 미리보기 루프 동안에는 자동완성 흔적 글자가 겹쳐 보이지 않게 한다
.id.is-demo .id__input::placeholder { color: transparent; }
// 좁은 화면 — 표시와 여백만 줄인다. 값을 올리지 않는다.
@media (max-width: 340px), (max-height: 220px) {
.id { gap: $sp-2; }
.id__input { width: 120px; font-size: 13px; }
.id__grid { width: 56px; height: 56px; }
.id__mini { width: 32px; height: 32px; }
.id__list { gap: $sp-3; }
.id__name { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
// .id.is-demo .id__cell (0,3,0) 를 이기려면 !important 가 필요하다.
// 움직임만 끄고 무늬는 그대로 남긴다
.id__cell { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.id {
display: grid;
justify-items: center;
gap: 12px;
width: min(360px, 92vw);
}
.id__field {
display: flex;
align-items: center;
gap: 8px;
}
.id__label {
color: #17141a;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
}
.id__input {
width: 150px;
padding: 8px 12px;
border: 2px solid #17141a;
border-radius: 8px;
background: transparent;
color: #17141a;
font-family: inherit;
font-weight: 600;
font-size: 14px;
line-height: 1.3;
}
.id__input:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.id__grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
width: 64px;
height: 64px;
}
.id__cell {
transform-origin: center;
border-radius: 4px;
background: rgba(23, 20, 26, 0.12);
}
.id__grid[data-tone="0"] .id__cell.is-on,
.id__mini[data-tone="0"] .id__cell.is-on {
background: #7c90be;
}
.id__grid[data-tone="1"] .id__cell.is-on,
.id__mini[data-tone="1"] .id__cell.is-on {
background: #9791c9;
}
.id__grid[data-tone="2"] .id__cell.is-on,
.id__mini[data-tone="2"] .id__cell.is-on {
background: #479082;
}
.id__grid[data-tone="3"] .id__cell.is-on,
.id__mini[data-tone="3"] .id__cell.is-on {
background: #ab5488;
}
.id__grid[data-tone="4"] .id__cell.is-on,
.id__mini[data-tone="4"] .id__cell.is-on {
background: #c2361a;
}
.id__grid[data-tone="5"] .id__cell.is-on,
.id__mini[data-tone="5"] .id__cell.is-on {
background: #3b4a63;
}
.id__list {
display: flex;
gap: 16px;
margin: 0;
padding: 0;
list-style: none;
}
.id__item {
display: grid;
justify-items: center;
gap: 4px;
}
.id__mini {
display: grid;
grid-template-columns: repeat(5, 1fr);
width: 40px;
height: 40px;
}
.id__name {
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.2;
}
.id.is-demo .id__cell {
animation-name: idBreathe;
animation-duration: 1s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * 40ms);
animation-fill-mode: backwards;
}
@keyframes idBreathe {
0% {
transform: scale(0.72);
}
50% {
transform: scale(1);
}
100% {
transform: scale(0.72);
}
}
.id.is-demo .id__input::placeholder {
color: transparent;
}
@media (max-width: 340px), (max-height: 220px) {
.id {
gap: 8px;
}
.id__input {
width: 120px;
font-size: 13px;
}
.id__grid {
width: 56px;
height: 56px;
}
.id__mini {
width: 32px;
height: 32px;
}
.id__list {
gap: 12px;
}
.id__name {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.id__cell {
animation: none !important;
}
}(function () {
var root = document.querySelector('.id');
var input = root.querySelector('.id__input');
var grid = root.querySelector('.id__grid');
// 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;
}
function buildCells(el) {
for (var i = 0; i < 25; i++) {
var cell = document.createElement('span');
cell.className = 'id__cell';
el.appendChild(cell);
}
}
// 켜진 칸만 클래스가 붙고, 색도 해시로 고른 data-tone 번호가 정한다(scss 가 칠한다)
function render(el, name) {
var cells = cellsOf(name);
el.dataset.tone = String(hash32(name) % 6);
var kids = el.children;
for (var i = 0; i < 25; i++) {
kids[i].classList.toggle('is-on', cells[i] === 1);
kids[i].style.setProperty('--i', String(i));
}
}
buildCells(grid);
render(grid, input.value);
// 작은 무늬 셋 — Name 표시에서 각각 계산한다
var items = root.querySelectorAll('.id__item');
Array.prototype.forEach.call(items, function (item) {
var mini = item.querySelector('.id__mini');
buildCells(mini);
render(mini, item.querySelector('.id__name').textContent);
});
// 입력이 바뀌면 그 자리에서 다시 그린다
input.addEventListener('input', function () { render(grid, input.value); });
// 사람이 손을 대면 미리보기 루프를 뗀다
function stopLoop() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./AvatarIdenticonPattern.module.scss";
export type AvatarIdenticonPatternProps = {
label?: string;
nameLabel?: string;
placeholder?: string;
name?: string;
smallNames?: string[];
/** 칸 숨쉬기 한 바퀴(ms) — 2초의 약수 */
duration?: number;
easing?: string;
/** 오렌지 무대 글자색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type CellVars = CSSProperties & { "--i"?: string };
// ambient 타입에 React 이벤트 제네릭이 없어 로컬 타입을 쓴다
type InputEvt = { target: { value: string } };
// FNV-1a 32비트 — 같은 이름은 늘 같은 정수를 돌려준다
function hash32(text: string): number {
let h = 2166136261;
for (let 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: string): number[] {
const h = hash32(text);
const cells: number[] = [];
for (let row = 0; row < 5; row++) {
const 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;
}
function IdenticonGrid({ className, name }: { className: string; name: string }): ReactNode {
// 켜진 칸만 색이 들어간다 — 색도 해시로 고른 data-tone 번호가 정한다(scss 가 칠한다)
return (
<div className={className} data-tone={String(hash32(name) % 6)} aria-hidden="true">
{cellsOf(name).map((on, i) => (
<span
key={i}
className={on ? `${styles.cell} ${styles.cellOn}` : styles.cell}
style={{ "--i": String(i) } as CellVars}
/>
))}
</div>
);
}
export default function AvatarIdenticonPattern({
label = "이름 무늬 아바타",
nameLabel = "이름",
placeholder = "이름을 입력하세요",
name = "관리자",
smallNames = ["디자이너", "기획자", "운영자"],
duration = 1000,
easing = "ease-out",
color = "#17141a",
}: AvatarIdenticonPatternProps): ReactNode {
const [value, setValue] = useState<string>(name);
const [loop, setLoop] = useState<boolean>(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section
className={loop ? `${styles.id} ${styles.isDemo}` : styles.id}
style={vars}
aria-label={label}
onPointerDown={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<div className={styles.field}>
<label className={styles.label} htmlFor="id-name">{nameLabel}</label>
<input
className={styles.input}
id="id-name"
type="text"
value={value}
autoComplete="off"
spellCheck={false}
placeholder={placeholder}
onChange={(e: InputEvt) => setValue(e.target.value)}
/>
</div>
{/* 입력이 바뀌면 그 자리에서 다시 그린다 */}
<IdenticonGrid className={styles.grid} name={value} />
<ul className={styles.list}>
{smallNames.map((smallName) => (
<li key={smallName} className={styles.item}>
<IdenticonGrid className={styles.mini} name={smallName} />
<span className={styles.smallName}>{smallName}</span>
</li>
))}
</ul>
</section>
);
}03 · Name-pattern avatar — Measured area 3.54% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Use it in team and repository lists where initials collide.
// FNV-1a 32-bit — the same name always returns the same integer
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;
}
// Fifteen bits land in columns 0, 1, 2; columns 0-4 and 1-3 mirror each other to fill 25 cells
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);
}
// Each cell breathes on its own delay — a lit cell never goes out through opacity
.id.is-demo .id__cell {
animation-name: idBreathe;
animation-duration: $duration; // 1s — a divisor of 2s
animation-delay: calc(var(--i) * 40ms);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
04Shape and size set
What you see
One avatar moves between circle, squircle, and hexagon while four sizes — 28, 36, 48, and 64px — stand in a row. The letter size and the ring width are ratios of the single --size variable, so every combination keeps its proportions.
<main class="stage">
<section class="as is-demo" data-shape="circle" aria-label="Avatar shape and size">
<div class="as__shapes" role="group" aria-label="Choose a shape">
<button class="as__shape-btn" type="button" data-shape-btn="circle" aria-pressed="true">
<span class="as__swatch as__swatch--circle" aria-hidden="true"></span>Circle
</button>
<button class="as__shape-btn" type="button" data-shape-btn="squircle" aria-pressed="false">
<span class="as__swatch as__swatch--squircle" aria-hidden="true"></span>Squircle
</button>
<button class="as__shape-btn" type="button" data-shape-btn="hex" aria-pressed="false">
<span class="as__swatch as__swatch--hex" aria-hidden="true"></span>Hexagon
</button>
</div>
<div class="as__row">
<span class="as__slot" style="--size: 28px; --pop-delay: 0ms"><span class="as__av" data-name="Designer" aria-hidden="true"></span></span>
<span class="as__slot" style="--size: 36px; --pop-delay: 120ms"><span class="as__av" data-name="Designer" aria-hidden="true"></span></span>
<span class="as__slot" style="--size: 48px; --pop-delay: 240ms"><span class="as__av" data-name="Designer" aria-hidden="true"></span></span>
<span class="as__slot" style="--size: 64px; --pop-delay: 360ms"><span class="as__av" data-name="Designer" aria-hidden="true"></span></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 279 아바타 팔레트 — 이니셜 글자는 언제나 $subject-cream. 괄호 안은 크림 글자 기준 실측 대비비
$av-blue: #7c90be; // 5.29:1
$av-violet: #9791c9; // 5.76:1
$av-teal: #479082; // 5.79:1
$av-plum: #ab5488; // 6.52:1
$av-brick: $error; // 오류 토큰 — 5.13:1. 리터럴 금지 (BD10)
$av-slate: #3b4a63; // 8.40:1
$av-mist: #b6b0c0; // 잉크 무대 보조 글자 8.65:1
$av-deep: #4a4452; // 옐로 무대 보조 글자 6.49:1 · 페이퍼 무대 9.38:1
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 모양 컷·팝 한 바퀴
$easing: $ease-pop; // 팝이 탄성처럼 튀어오른다
$color: $av-violet; // 아바타 면 색
@include stage($stage-paper);
.as {
display: grid;
justify-items: center;
gap: $sp-5;
width: min(360px, 90vw);
}
// 모양 고르는 칩들
.as__shapes { display: flex; gap: $sp-2; }
.as__shape-btn {
display: inline-flex;
align-items: center;
gap: $sp-2;
margin: 0;
padding: $sp-2 $sp-4;
border: 2px solid $av-deep;
border-radius: $r-pill;
background: transparent;
color: $av-deep;
font-family: inherit;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
cursor: pointer;
transition: background-color $dur-quick $ease-spring, color $dur-quick $ease-spring;
}
.as__shape-btn:hover { background: rgba(74, 68, 82, .08); }
.as__shape-btn:focus-visible { outline: 2px solid $color; outline-offset: 3px; }
.as__shape-btn[aria-pressed="true"] {
background: $av-deep;
color: $subject-cream;
}
// 칩 안 작은 모양 견본 — 실제 아바타와 같은 clip-path 값으로 미리 보여 준다
.as__swatch {
width: 14px;
height: 14px;
background: currentColor;
}
.as__swatch--circle { clip-path: circle(50%); }
.as__swatch--squircle { clip-path: inset(0 round 34%); }
.as__swatch--hex { clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%); }
// 네 크기 아바타 — --size 하나로 글자 크기·테두리 두께가 비율로 따라간다
.as__row {
display: flex;
align-items: center;
gap: $sp-4;
}
.as__slot { display: block; width: var(--size); }
.as__av {
box-sizing: border-box;
display: grid;
place-items: center;
width: var(--size);
aspect-ratio: 1;
background: $color;
color: $subject-cream;
font-weight: 700;
font-size: calc(var(--size) * .4);
line-height: 1;
border: calc(var(--size) / 14) solid $av-deep;
}
// 정적 모양 — 루프가 없어도 data-shape 대로 그대로 보인다 (.is-demo 를 뗀 화면이 비면 안 된다)
.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%); }
// ── 격자 미리보기 루프 ────────────────────────────────
// 아바타는 scale 로 시차 팝(투명도 아님 — 팝은 크기로만 보여 준다).
// 모양은 한 바퀴를 세 구간으로 나눠 컷 전환하는데, 세 clip-path 값이 서로 다른 함수
// (circle / inset / polygon)라 보간되지 않으니 steps(1, end) 로 한 프레임에 점프시키는 것이 맞다.
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로만 쓴다
.as.is-demo .as__slot {
animation-name: asPop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: var(--pop-delay, 0ms);
animation-fill-mode: backwards;
}
.as.is-demo .as__av {
animation-name: asShape;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes asPop {
0% { scale: .4; }
16% { scale: 1.1; }
28% { scale: 1; }
100% { scale: 1; }
}
// 컷 타이밍: 0% 원 → 34% 모난 원 → 67% 육각 → 88% 원 복귀 (루프 복귀는 90% 안에)
@keyframes asShape {
0% { clip-path: circle(50%); }
34% { clip-path: inset(0 round 34%); }
67% { clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%); }
88%, 100% { clip-path: circle(50%); }
}
// 좁은 화면 — 여백·글자만 줄인다. 값을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.as { gap: $sp-3; }
.as__row { gap: $sp-2; }
.as__shape-btn { padding: $sp-1 $sp-3; font-size: 12px; }
.as__swatch { width: 12px; height: 12px; }
}
@media (prefers-reduced-motion: reduce) {
// .as.is-demo .as__slot (0,3,0) 를 이기려면 !important. 움직임만 끄고 모양·글자는 남긴다
.as__slot { animation: none !important; }
.as__av { animation: none !important; }
.as__shape-btn { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.as {
display: grid;
justify-items: center;
gap: 20px;
width: min(360px, 90vw);
}
.as__shapes {
display: flex;
gap: 8px;
}
.as__shape-btn {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 8px 16px;
border: 2px solid #4a4452;
border-radius: 999px;
background: transparent;
color: #4a4452;
font-family: inherit;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
cursor: pointer;
transition: background-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.as__shape-btn:hover {
background: rgba(74, 68, 82, 0.08);
}
.as__shape-btn:focus-visible {
outline: 2px solid #9791c9;
outline-offset: 3px;
}
.as__shape-btn[aria-pressed=true] {
background: #4a4452;
color: #fff7e6;
}
.as__swatch {
width: 14px;
height: 14px;
background: currentColor;
}
.as__swatch--circle {
clip-path: circle(50%);
}
.as__swatch--squircle {
clip-path: inset(0 round 34%);
}
.as__swatch--hex {
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
}
.as__row {
display: flex;
align-items: center;
gap: 16px;
}
.as__slot {
display: block;
width: var(--size);
}
.as__av {
box-sizing: border-box;
display: grid;
place-items: center;
width: var(--size);
aspect-ratio: 1;
background: #9791c9;
color: #fff7e6;
font-weight: 700;
font-size: calc(var(--size) * 0.4);
line-height: 1;
border: calc(var(--size) / 14) solid #4a4452;
}
.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.is-demo .as__slot {
animation-name: asPop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-delay: var(--pop-delay, 0ms);
animation-fill-mode: backwards;
}
.as.is-demo .as__av {
animation-name: asShape;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes asPop {
0% {
scale: 0.4;
}
16% {
scale: 1.1;
}
28% {
scale: 1;
}
100% {
scale: 1;
}
}
@keyframes asShape {
0% {
clip-path: circle(50%);
}
34% {
clip-path: inset(0 round 34%);
}
67% {
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
}
88%, 100% {
clip-path: circle(50%);
}
}
@media (max-width: 340px), (max-height: 220px) {
.as {
gap: 12px;
}
.as__row {
gap: 8px;
}
.as__shape-btn {
padding: 4px 12px;
font-size: 12px;
}
.as__swatch {
width: 12px;
height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.as__slot {
animation: none !important;
}
.as__av {
animation: none !important;
}
.as__shape-btn {
transition: none !important;
}
}(function () {
var root = document.querySelector('.as');
// 이니셜은 표시 문자열에서 계산한다 — 하드코딩하지 않아야 en/ja 언어팩에서도 따라간다
function initialOf(name) {
var words = (name || '').trim().split(/\s+/);
if (/[A-Za-z]/.test(words[0].charAt(0))) {
return words.slice(0, 2).map(function (w) { return w.charAt(0).toUpperCase(); }).join('');
}
return words[0].charAt(0);
}
Array.prototype.forEach.call(root.querySelectorAll('.as__av'), function (av) {
av.textContent = initialOf(av.getAttribute('data-name'));
});
// 모양 버튼 — 뿌리의 data-shape 를 갈아 네 아바타가 전부 그 모양이 된다
var btns = root.querySelectorAll('[data-shape-btn]');
Array.prototype.forEach.call(btns, function (btn) {
btn.addEventListener('click', function () {
root.setAttribute('data-shape', btn.getAttribute('data-shape-btn'));
Array.prototype.forEach.call(btns, function (b) {
b.setAttribute('aria-pressed', b === btn ? 'true' : 'false');
});
});
});
// 사람이 손을 대면 미리보기 루프를 뗀다
function stopLoop() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./AvatarShapeSizeSet.module.scss";
export type AvatarShapeSizeSetProps = {
label?: string;
pickerLabel?: string;
name?: string;
shapeLabels?: { circle: string; squircle: string; hex: string };
sizes?: number[];
/** 모양 컷·팝 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 아바타 면 색 */
color?: string;
};
type Vars = CSSProperties & {
"--size"?: string;
"--pop-delay"?: string;
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
// 이니셜은 표시 문자열에서 계산한다 — 하드코딩하지 않아야 언어팩이 바뀌어도 따라간다
function initialOf(name: string): string {
const words = name.trim().split(/\s+/);
if (/[A-Za-z]/.test(words[0].charAt(0))) {
return words.slice(0, 2).map((w) => w.charAt(0).toUpperCase()).join("");
}
return words[0].charAt(0);
}
function swatchClass(key: string): string {
if (key === "squircle") return styles.swatchSquircle;
if (key === "hex") return styles.swatchHex;
return styles.swatchCircle;
}
export default function AvatarShapeSizeSet({
label = "아바타 모양과 크기",
pickerLabel = "모양 고르기",
name = "디자이너",
shapeLabels = { circle: "원", squircle: "모난 원", hex: "육각" },
sizes = [28, 36, 48, 64],
duration = 2000,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#5b4bd6",
}: AvatarShapeSizeSetProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const [shape, setShape] = useState<string>("circle");
const initial = initialOf(name);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const shapes = [
{ key: "circle", text: shapeLabels.circle },
{ key: "squircle", text: shapeLabels.squircle },
{ key: "hex", text: shapeLabels.hex },
];
return (
<section
className={loop ? `${styles.as} ${styles.isDemo}` : styles.as}
data-shape={shape}
style={vars}
aria-label={label}
onPointerDown={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<div className={styles.shapes} role="group" aria-label={pickerLabel}>
{shapes.map((s) => (
<button
key={s.key}
className={styles.shapeBtn}
type="button"
aria-pressed={shape === s.key ? "true" : "false"}
onClick={() => setShape(s.key)}
>
<span className={`${styles.swatch} ${swatchClass(s.key)}`} aria-hidden="true" />
{s.text}
</button>
))}
</div>
<div className={styles.row}>
{sizes.map((size, i) => (
<span
key={size}
className={styles.slot}
style={{ "--size": `${size}px`, "--pop-delay": `${i * 120}ms` } as Vars}
>
<span className={styles.av} aria-hidden="true">{initial}</span>
</span>
))}
</div>
</section>
);
}04 · Shape and size set — Measured area 6.111% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Use it in a design system that reuses one avatar across screens.
// Every shape is a clip-path — different functions never interpolate, so swaps jump as steps cuts
.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; }
// One --size drives the letter size and ring width by ratio (no hardcoded px)
.as__av {
width: var(--size);
font-size: calc(var(--size) * .4);
border: calc(var(--size) / 14) solid $av-deep;
}
05Overlapping stack with +N
What happens on screen
Only four people stay visible and the rest fold into a single +N slot; a pointer or focus unfolds the stack into one slot per person, and pressing +N opens the folded roster below. The overlap itself is a negative margin drawn from the spacing tokens.
<main class="stage">
<section class="st is-demo" aria-label="Document collaborators">
<div class="st__stack">
<button class="st__av st__av--1" type="button" style="--i: 0" data-name="Admin" aria-label="Admin"></button>
<button class="st__av st__av--2" type="button" style="--i: 1" data-name="Editor" aria-label="Editor"></button>
<button class="st__av st__av--3" type="button" style="--i: 2" data-name="Designer" aria-label="Designer"></button>
<button class="st__av st__av--4" type="button" style="--i: 3" data-name="Developer" aria-label="Developer"></button>
<button class="st__more" type="button" style="--i: 4" aria-expanded="false" aria-label="Show remaining participants">+3</button>
</div>
<p class="st__line">Seven people are on this document</p>
<ul class="st__folded">
<li class="st__row" hidden><span class="st__mini st__mini--5" data-name="Reviewer" aria-hidden="true"></span>Reviewer</li>
<li class="st__row" hidden><span class="st__mini st__mini--6" data-name="Translator" aria-hidden="true"></span>Translator</li>
<li class="st__row" hidden><span class="st__mini st__mini--7" data-name="Operator" aria-hidden="true"></span>Operator</li>
</ul>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 279 아바타 팔레트 — 이니셜 글자는 언제나 $subject-cream. 괄호 안은 크림 글자 기준 실측 대비비
$av-blue: #7c90be; // 5.29:1
$av-violet: #9791c9; // 5.76:1
$av-teal: #479082; // 5.79:1
$av-plum: #ab5488; // 6.52:1
$av-brick: $error; // 오류 토큰 — 5.13:1. 리터럴 금지 (BD10)
$av-slate: #3b4a63; // 8.40:1
$av-mist: #b6b0c0; // 잉크 무대 보조 글자 8.65:1
$av-deep: #4a4452; // 옐로 무대 보조 글자 6.49:1 · 페이퍼 무대 9.38:1
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 벌어졌다 모이는 한 바퀴
$easing: $ease-spring;
$color: $av-slate; // +N 칸 면 색
@include stage($stage-ink);
.st {
display: grid;
justify-items: start;
gap: $sp-3;
width: min(360px, 90vw);
}
.st__stack {
display: flex;
align-items: center;
padding: $sp-1;
}
// 겹침 — 뒤에 오는 요소마다 음수 margin-left (4px 격자 배수)
.st__stack > * + * { margin-left: -#{$sp-3}; }
.st__av {
position: relative;
z-index: calc(4 - var(--i)); // 왼쪽이 위에 오도록 내림차순
display: grid;
place-items: center;
width: 40px;
aspect-ratio: 1;
margin: 0;
padding: 0;
border: 2px solid $stage-ink; // 겹친 사람 사이 윤곽
border-radius: 50%;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 16px;
line-height: 1;
cursor: pointer;
transition: transform $dur-base $ease-spring;
}
.st__av:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
// 사람마다 다른 면 색 — 같은 편의 팔레트를 쓴다
.st__av--1 { background: $av-blue; }
.st__av--2 { background: $av-violet; }
.st__av--3 { background: $av-teal; }
.st__av--4 { background: $av-plum; }
// +N 칸 — 접힌 인원 수만 보이는 자리
.st__more {
position: relative;
z-index: 0;
display: grid;
place-items: center;
width: 40px;
aspect-ratio: 1;
margin: 0;
padding: 0;
border: 2px solid $stage-ink;
border-radius: 50%;
background: $color;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
transition: transform $dur-base $ease-spring;
}
.st__more:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
// 펼침 — 겹친 만큼(사람 하나당 $sp-3)을 translateX 로 오른쪽으로 되돌린다
.st.is-open .st__av,
.st.is-open .st__more { transform: translateX(calc(var(--i) * #{$sp-3})); }
.st__line {
margin: 0;
color: $av-mist; // 잉크 무대 보조 글자 8.65:1
font-weight: 500;
font-size: 13px;
line-height: 1.4;
}
// 접힌 인원 목록 — 실제 DOM 에 있다가 hidden 으로 여닫는다
.st__folded {
display: grid;
gap: $sp-2;
margin: 0;
padding: 0;
list-style: none;
}
.st__row {
display: flex;
align-items: center;
gap: $sp-2;
color: $subject-cream; // 17.11:1
font-weight: 600;
font-size: 13px;
line-height: 1.2;
}
// UA 의 [hidden] 규칙이 display:flex 에 지지 않게 직접 눌러 둔다
.st__row[hidden] { display: none; }
.st__mini {
display: grid;
place-items: center;
width: 20px;
aspect-ratio: 1;
border-radius: 50%;
color: $subject-cream;
font-weight: 700;
font-size: 9px;
line-height: 1;
}
.st__mini--5 { background: $av-brick; }
.st__mini--6 { background: $av-slate; }
.st__mini--7 { background: $av-blue; }
// ── 격자 미리보기 루프 ────────────────────────────────
// 닫힘(0%) → 펼침(40%) → 펼침 유지(78%) → 닫힘(90%) — 텔레포트 없이 이징으로 왕복한다.
// 사람마다 animation-delay 로 시차를 두어 한 사람씩 벌어진다.
// animation 단축은 delay 를 0 으로 리셋하므로 낱개 속성으로만 쓴다
.st.is-demo .st__av,
.st.is-demo .st__more {
animation-name: stSpread;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * 45ms);
animation-fill-mode: backwards;
}
@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); }
}
// 좁은 화면 — 글자만 줄인다. 값을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.st { gap: $sp-2; }
.st__line { font-size: 12px; }
.st__row { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
// .st.is-demo .st__av (0,3,0) 를 이기려면 !important. 움직임만 끄고 스택·목록은 남긴다
.st__av, .st__more { animation: none !important; transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.st {
display: grid;
justify-items: start;
gap: 12px;
width: min(360px, 90vw);
}
.st__stack {
display: flex;
align-items: center;
padding: 4px;
}
.st__stack > * + * {
margin-left: -12px;
}
.st__av {
position: relative;
z-index: calc(4 - var(--i));
display: grid;
place-items: center;
width: 40px;
aspect-ratio: 1;
margin: 0;
padding: 0;
border: 2px solid #17141a;
border-radius: 50%;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 16px;
line-height: 1;
cursor: pointer;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.st__av:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.st__av--1 {
background: #7c90be;
}
.st__av--2 {
background: #9791c9;
}
.st__av--3 {
background: #479082;
}
.st__av--4 {
background: #ab5488;
}
.st__more {
position: relative;
z-index: 0;
display: grid;
place-items: center;
width: 40px;
aspect-ratio: 1;
margin: 0;
padding: 0;
border: 2px solid #17141a;
border-radius: 50%;
background: #3b4a63;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1;
cursor: pointer;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.st__more:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.st.is-open .st__av,
.st.is-open .st__more {
transform: translateX(calc(var(--i) * 12px));
}
.st__line {
margin: 0;
color: #b6b0c0;
font-weight: 500;
font-size: 13px;
line-height: 1.4;
}
.st__folded {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.st__row {
display: flex;
align-items: center;
gap: 8px;
color: #fff7e6;
font-weight: 600;
font-size: 13px;
line-height: 1.2;
}
.st__row[hidden] {
display: none;
}
.st__mini {
display: grid;
place-items: center;
width: 20px;
aspect-ratio: 1;
border-radius: 50%;
color: #fff7e6;
font-weight: 700;
font-size: 9px;
line-height: 1;
}
.st__mini--5 {
background: #c2361a;
}
.st__mini--6 {
background: #3b4a63;
}
.st__mini--7 {
background: #7c90be;
}
.st.is-demo .st__av,
.st.is-demo .st__more {
animation-name: stSpread;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * 45ms);
animation-fill-mode: backwards;
}
@keyframes stSpread {
0% {
transform: translateX(0);
}
40% {
transform: translateX(calc(var(--i) * 12px));
}
78% {
transform: translateX(calc(var(--i) * 12px));
}
90% {
transform: translateX(0);
}
100% {
transform: translateX(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.st {
gap: 8px;
}
.st__line {
font-size: 12px;
}
.st__row {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.st__av, .st__more {
animation: none !important;
transition: none !important;
}
}(function () {
var root = document.querySelector('.st');
var stack = root.querySelector('.st__stack');
var more = root.querySelector('.st__more');
var rows = root.querySelectorAll('.st__row');
// 이니셜은 표시 문자열에서 계산한다 — 하드코딩하지 않아야 en/ja 언어팩에서도 따라간다
function initialOf(name) {
var words = (name || '').trim().split(/\s+/);
if (/[A-Za-z]/.test(words[0].charAt(0))) {
return words.slice(0, 2).map(function (w) { return w.charAt(0).toUpperCase(); }).join('');
}
return words[0].charAt(0);
}
Array.prototype.forEach.call(root.querySelectorAll('[data-name]'), function (el) {
el.textContent = initialOf(el.getAttribute('data-name'));
});
function stopLoop() { root.classList.remove('is-demo'); }
// 손이나 초점이 스택에 들어오면 뿌리에 is-open — 겹침이 translateX 로 풀린다
// (pointerenter 도 루프를 뗀다 — 이 데모의 본 상호작용이 호버라서 애니메이션이 덮으면 죽는다)
function openStack() { stopLoop(); root.classList.add('is-open'); }
function closeStack() { root.classList.remove('is-open'); }
stack.addEventListener('pointerenter', openStack);
stack.addEventListener('pointerleave', closeStack);
stack.addEventListener('focusin', openStack);
stack.addEventListener('focusout', closeStack);
root.addEventListener('pointerdown', stopLoop);
// +N 버튼 — 접힌 인원을 아래 목록으로 펼친다 (실제 DOM 요소를 hidden 으로 여닫는다)
var expanded = false;
more.addEventListener('click', function () {
expanded = !expanded;
more.setAttribute('aria-expanded', expanded ? 'true' : 'false');
Array.prototype.forEach.call(rows, function (row) { row.hidden = !expanded; });
});
})();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./AvatarStackOverflow.module.scss";
export type AvatarStackOverflowProps = {
label?: string;
/** 앞 네 명은 스택에, 나머지는 +N 뒤에 접힌다 */
people?: string[];
line?: string;
moreLabel?: string;
/** 벌어졌다 모이는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** +N 칸 면 색 */
color?: string;
};
type Vars = CSSProperties & {
"--i"?: string;
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
// 이니셜은 표시 문자열에서 계산한다 — 하드코딩하지 않아야 언어팩이 바뀌어도 따라간다
function initialOf(name: string): string {
const words = name.trim().split(/\s+/);
if (/[A-Za-z]/.test(words[0].charAt(0))) {
return words.slice(0, 2).map((w) => w.charAt(0).toUpperCase()).join("");
}
return words[0].charAt(0);
}
function faceClass(i: number): string {
const map = [styles.face1, styles.face2, styles.face3, styles.face4, styles.face5, styles.face6, styles.face1];
return map[i % map.length] ?? styles.face1;
}
export default function AvatarStackOverflow({
label = "문서 참여자",
people = ["관리자", "편집자", "디자이너", "개발자", "검토자", "번역가", "운영자"],
line = "문서에 일곱 명이 참여 중",
moreLabel = "접힌 참여자 펼치기",
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#3b4a63",
}: AvatarStackOverflowProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const [open, setOpen] = useState<boolean>(false);
const [expanded, setExpanded] = useState<boolean>(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const visible = people.slice(0, 4);
const folded = people.slice(4);
return (
<section
className={loop ? `${styles.st} ${styles.isDemo}` : open ? `${styles.st} ${styles.isOpen}` : styles.st}
style={vars}
aria-label={label}
onPointerDown={() => setLoop(false)}
>
<div
className={styles.stack}
onPointerEnter={() => { setLoop(false); setOpen(true); }}
onPointerLeave={() => setOpen(false)}
onFocus={() => { setLoop(false); setOpen(true); }}
onBlur={() => setOpen(false)}
>
{visible.map((name, i) => (
<button
key={name}
className={`${styles.av} ${faceClass(i)}`}
type="button"
style={{ "--i": `${i}` } as Vars}
aria-label={name}
>
{initialOf(name)}
</button>
))}
<button
className={styles.more}
type="button"
style={{ "--i": `${visible.length}` } as Vars}
aria-expanded={expanded ? "true" : "false"}
aria-label={moreLabel}
onClick={() => setExpanded(!expanded)}
>
+{folded.length}
</button>
</div>
<p className={styles.line}>{line}</p>
{/* 접힌 인원 — 실제 DOM 요소를 hidden 으로 여닫는다 */}
<ul className={styles.folded}>
{folded.map((name, i) => (
<li key={name} className={styles.row} hidden={!expanded}>
<span className={`${styles.mini} ${faceClass(visible.length + i)}`} aria-hidden="true">
{initialOf(name)}
</span>
{name}
</li>
))}
</ul>
</section>
);
}05 · Overlapping stack with +N — Measured area 4.666% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Use it where one row has to say who is on a document or a task.
// The overlap is a negative margin — spacing comes from the 4px grid tokens only
.st__stack > * + * { margin-left: -#{$sp-3}; }
// Unfolding pays the overlap back with translateX — a round trip, never a teleport
@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); }
}
// Folded roster rows — display:flex beats the UA's hidden, so one more line presses it down
.st__row[hidden] { display: none; }
06Presence dot
What moves
A mask punches a round hole at the avatar's edge and the dot sits inside that hole, so it never covers the face or slides past the rim. Every state change swaps the adjacent text and the announced name together.
<main class="stage">
<section class="pd is-demo" data-state="online" aria-label="Presence indicator">
<div class="pd__body">
<div class="pd__wrap" role="img" aria-label="Planner, Online">
<span class="pd__av" data-name="Planner"></span>
<span class="pd__dot" aria-hidden="true"></span>
</div>
<div class="pd__meta">
<p class="pd__name">Planner</p>
<p class="pd__state">
<span data-key="online">Online</span>
<span data-key="away">Away</span>
<span data-key="busy">Busy</span>
</p>
</div>
</div>
<button class="pd__btn" type="button">Change status</button>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 279 아바타 팔레트 — 이니셜 글자는 언제나 $subject-cream. 괄호 안은 크림 글자 기준 실측 대비비
$av-blue: #7c90be; // 5.29:1
$av-violet: #9791c9; // 5.76:1
$av-teal: #479082; // 5.79:1
$av-plum: #ab5488; // 6.52:1
$av-brick: $error; // 오류 토큰 — 5.13:1. 리터럴 금지 (BD10)
$av-slate: #3b4a63; // 8.40:1
$av-mist: #b6b0c0; // 잉크 무대 보조 글자 8.65:1
$av-deep: #4a4452; // 옐로 무대 보조 글자 6.49:1 · 페이퍼 무대 9.38:1
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 세 상태를 한 바퀴에 도는 시간
$easing: $ease-spring;
$color: $av-violet; // 아바타 면 색
@include stage($stage-yellow);
.pd {
display: grid;
justify-items: center;
gap: $sp-4;
width: min(320px, 90vw);
}
.pd__body {
display: flex;
align-items: center;
gap: $sp-4;
}
.pd__wrap {
position: relative;
display: block;
width: 64px;
height: 64px;
}
.pd__av {
display: grid;
place-items: center;
width: 64px;
aspect-ratio: 1;
border-radius: 50%;
background: $color;
color: $subject-cream;
font-weight: 700;
font-size: 26px;
line-height: 1;
// 이 데모의 주제 — 아바타 면에 마스크로 구멍을 파고 점을 그 구멍에 앉힌다.
// 점이 얼굴이나 가장자리를 덮지 않고, 뚫린 자리에 정확히 들어간다
-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%);
}
.pd__meta { display: grid; gap: $sp-1; }
.pd__name {
margin: 0;
color: $stage-ink; // 옐로 무대 12.63:1 (크림 1.35:1 금지)
font-weight: 700;
font-size: 15px;
line-height: 1.3;
}
// 상태 글자 — 정적 규칙: 현재 상태 한 줄만 보인다
.pd__state {
margin: 0;
color: $stage-ink;
font-weight: 600;
font-size: 13px;
line-height: 1.3;
}
.pd__state span { display: none; }
.pd[data-state="online"] .pd__state span[data-key="online"],
.pd[data-state="away"] .pd__state span[data-key="away"],
.pd[data-state="busy"] .pd__state span[data-key="busy"] { display: inline; }
// 상태별 점 색 — 접속(민트) · 자리 비움(짙은 회자주) · 다른 용무(벽돌)
.pd[data-state="online"] .pd__dot { background: $av-teal; }
.pd[data-state="away"] .pd__dot { background: $av-deep; }
.pd[data-state="busy"] .pd__dot { background: $error; }
.pd__btn {
margin: 0;
padding: $sp-2 $sp-5;
border: 0;
border-radius: $r-pill;
background: $stage-ink;
color: $stage-yellow; // 12.63:1
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1.2;
cursor: pointer;
transition: transform $dur-quick $ease-spring;
}
.pd__btn:hover { transform: translateY(-2px); }
.pd__btn:focus-visible { outline: 2px solid $stage-ink; outline-offset: 3px; }
// ── 격자 미리보기 루프 ────────────────────────────────
// 세 상태를 steps(1, end) 로 0% / 34% / 67% 에 컷 전환하고 88% 에 처음으로 복귀한다.
// 상태 교체를 페이드로 하면 poster 에 반투명 유령이 겹치므로 무조건 컷.
// 컷만으로는 움직인프레임이 모자라니 점은 scale 로 조용히 뛴다(1000ms — 2s 의 약수)
.pd.is-demo .pd__dot {
animation-name: pdDotCut, pdDotBeat;
animation-duration: $duration, 1000ms;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
// 세 상태 글자를 같은 칸에 겹쳐 두고 컷 타이밍에 갈아 끼운다 — 두 글자가 겹쳐 보이지 않게
.pd.is-demo .pd__state { display: grid; }
.pd.is-demo .pd__state span {
display: inline;
grid-area: 1 / 1;
opacity: 0;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pd.is-demo .pd__state span[data-key="online"] { animation-name: pdTxtOnline; }
.pd.is-demo .pd__state span[data-key="away"] { animation-name: pdTxtAway; }
.pd.is-demo .pd__state span[data-key="busy"] { animation-name: pdTxtBusy; }
@keyframes pdDotCut {
0% { background-color: $av-teal; }
34% { background-color: $av-deep; }
67% { background-color: $error; }
88%, 100% { background-color: $av-teal; }
}
@keyframes pdDotBeat {
0% { scale: 1; }
50% { scale: 1.35; }
100% { scale: 1; }
}
@keyframes pdTxtOnline {
0% { opacity: 1; }
34% { opacity: 0; }
88% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes pdTxtAway {
0% { opacity: 0; }
34% { opacity: 1; }
67% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes pdTxtBusy {
0% { opacity: 0; }
67% { opacity: 1; }
88% { opacity: 0; }
100% { opacity: 0; }
}
// 좁은 화면 — 글자만 줄인다. 값을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.pd { gap: $sp-3; }
.pd__body { gap: $sp-3; }
.pd__name { font-size: 14px; }
.pd__state { font-size: 12px; }
.pd__btn { padding: $sp-1 $sp-4; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
// .pd.is-demo .pd__dot (0,3,0) 를 이기려면 !important.
// 움직임만 끄고, 상태 글자는 .is-demo 겹침 배치를 풀어 현재 상태 한 줄만 남긴다
.pd__dot { animation: none !important; }
.pd__state { display: block !important; }
.pd__state span { display: none !important; opacity: 1 !important; animation: none !important; }
.pd[data-state="online"] .pd__state span[data-key="online"],
.pd[data-state="away"] .pd__state span[data-key="away"],
.pd[data-state="busy"] .pd__state span[data-key="busy"] { display: inline !important; }
.pd__btn { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pd {
display: grid;
justify-items: center;
gap: 16px;
width: min(320px, 90vw);
}
.pd__body {
display: flex;
align-items: center;
gap: 16px;
}
.pd__wrap {
position: relative;
display: block;
width: 64px;
height: 64px;
}
.pd__av {
display: grid;
place-items: center;
width: 64px;
aspect-ratio: 1;
border-radius: 50%;
background: #9791c9;
color: #fff7e6;
font-weight: 700;
font-size: 26px;
line-height: 1;
-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%);
}
.pd__dot {
position: absolute;
left: 82%;
top: 82%;
width: 10px;
height: 10px;
border-radius: 50%;
background: #479082;
transform: translate(-50%, -50%);
}
.pd__meta {
display: grid;
gap: 4px;
}
.pd__name {
margin: 0;
color: #17141a;
font-weight: 700;
font-size: 15px;
line-height: 1.3;
}
.pd__state {
margin: 0;
color: #17141a;
font-weight: 600;
font-size: 13px;
line-height: 1.3;
}
.pd__state span {
display: none;
}
.pd[data-state=online] .pd__state span[data-key=online],
.pd[data-state=away] .pd__state span[data-key=away],
.pd[data-state=busy] .pd__state span[data-key=busy] {
display: inline;
}
.pd[data-state=online] .pd__dot {
background: #479082;
}
.pd[data-state=away] .pd__dot {
background: #4a4452;
}
.pd[data-state=busy] .pd__dot {
background: #c2361a;
}
.pd__btn {
margin: 0;
padding: 8px 20px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #ffd23f;
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1.2;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pd__btn:hover {
transform: translateY(-2px);
}
.pd__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.pd.is-demo .pd__dot {
animation-name: pdDotCut, pdDotBeat;
animation-duration: 2s, 1000ms;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
.pd.is-demo .pd__state {
display: grid;
}
.pd.is-demo .pd__state span {
display: inline;
grid-area: 1/1;
opacity: 0;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pd.is-demo .pd__state span[data-key=online] {
animation-name: pdTxtOnline;
}
.pd.is-demo .pd__state span[data-key=away] {
animation-name: pdTxtAway;
}
.pd.is-demo .pd__state span[data-key=busy] {
animation-name: pdTxtBusy;
}
@keyframes pdDotCut {
0% {
background-color: #479082;
}
34% {
background-color: #4a4452;
}
67% {
background-color: #c2361a;
}
88%, 100% {
background-color: #479082;
}
}
@keyframes pdDotBeat {
0% {
scale: 1;
}
50% {
scale: 1.35;
}
100% {
scale: 1;
}
}
@keyframes pdTxtOnline {
0% {
opacity: 1;
}
34% {
opacity: 0;
}
88% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes pdTxtAway {
0% {
opacity: 0;
}
34% {
opacity: 1;
}
67% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes pdTxtBusy {
0% {
opacity: 0;
}
67% {
opacity: 1;
}
88% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px), (max-height: 220px) {
.pd {
gap: 12px;
}
.pd__body {
gap: 12px;
}
.pd__name {
font-size: 14px;
}
.pd__state {
font-size: 12px;
}
.pd__btn {
padding: 4px 16px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.pd__dot {
animation: none !important;
}
.pd__state {
display: block !important;
}
.pd__state span {
display: none !important;
opacity: 1 !important;
animation: none !important;
}
.pd[data-state=online] .pd__state span[data-key=online],
.pd[data-state=away] .pd__state span[data-key=away],
.pd[data-state=busy] .pd__state span[data-key=busy] {
display: inline !important;
}
.pd__btn {
transition: none !important;
}
}(function () {
var root = document.querySelector('.pd');
var wrap = root.querySelector('.pd__wrap');
var av = root.querySelector('.pd__av');
var btn = root.querySelector('.pd__btn');
var order = ['online', 'away', 'busy'];
// 이니셜은 표시 문자열에서 계산한다 — 하드코딩하지 않아야 en/ja 언어팩에서도 따라간다
function initialOf(name) {
var words = (name || '').trim().split(/\s+/);
if (/[A-Za-z]/.test(words[0].charAt(0))) {
return words.slice(0, 2).map(function (w) { return w.charAt(0).toUpperCase(); }).join('');
}
return words[0].charAt(0);
}
av.textContent = initialOf(av.getAttribute('data-name'));
// 상태 글자는 코드에 박지 않고 HTML 안 요소에서 읽는다
var nameEl = root.querySelector('.pd__name');
function currentLabel() {
var el = root.querySelector('.pd__state span[data-key="' + root.getAttribute('data-state') + '"]');
return el ? el.textContent : '';
}
// aria-label 은 조사 앞뒤 공백이 생기지 않게 처음부터 완성된 문장으로 만든다
function applyLabel() {
wrap.setAttribute('aria-label', nameEl.textContent + ', ' + currentLabel());
}
applyLabel();
// online → away → busy → online
btn.addEventListener('click', function () {
var next = order[(order.indexOf(root.getAttribute('data-state')) + 1) % order.length];
root.setAttribute('data-state', next);
applyLabel();
});
// 사람이 손을 대면 미리보기 루프를 뗀다
function stopLoop() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./AvatarPresenceDot.module.scss";
export type AvatarPresenceDotProps = {
sectionLabel?: string;
name?: string;
stateTexts?: { online: string; away: string; busy: string };
buttonLabel?: string;
/** 세 상태를 한 바퀴에 도는 시간(ms) */
duration?: number;
easing?: string;
/** 아바타 면 색 */
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
// 이니셜은 표시 문자열에서 계산한다 — 하드코딩하지 않아야 언어팩이 바뀌어도 따라간다
function initialOf(name: string): string {
const words = name.trim().split(/\s+/);
if (/[A-Za-z]/.test(words[0].charAt(0))) {
return words.slice(0, 2).map((w) => w.charAt(0).toUpperCase()).join("");
}
return words[0].charAt(0);
}
const ORDER = ["online", "away", "busy"] as const;
export default function AvatarPresenceDot({
sectionLabel = "접속 상태 표시",
name = "기획자",
stateTexts = { online: "접속 중", away: "자리 비움", busy: "다른 용무 중" },
buttonLabel = "상태 바꾸기",
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#5b4bd6",
}: AvatarPresenceDotProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const [idx, setIdx] = useState<number>(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const state = ORDER[idx];
// aria-label 은 조사 앞뒤 공백이 생기지 않게 처음부터 완성된 문장으로 만든다
const wrapLabel = `${name}, ${stateTexts[state]}`;
return (
<section
className={loop ? `${styles.pd} ${styles.isDemo}` : styles.pd}
data-state={state}
style={vars}
aria-label={sectionLabel}
onPointerDown={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<div className={styles.body}>
<div className={styles.wrap} role="img" aria-label={wrapLabel}>
{/* 마스크가 아바타 면에 구멍을 파고, 점이 그 구멍에 앉는다 */}
<span className={styles.av}>{initialOf(name)}</span>
<span className={styles.dot} aria-hidden="true" />
</div>
<div className={styles.meta}>
<p className={styles.name}>{name}</p>
<p className={styles.state}>
<span data-key="online">{stateTexts.online}</span>
<span data-key="away">{stateTexts.away}</span>
<span data-key="busy">{stateTexts.busy}</span>
</p>
</div>
</div>
<button
className={styles.btn}
type="button"
onClick={() => setIdx((i) => (i + 1) % ORDER.length)}
>
{buttonLabel}
</button>
</section>
);
}06 · Presence dot — Measured area 0.557% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Use it on team screens that show who is online right now.
// The point of this demo — a mask punches a hole in the avatar face and the dot sits in it.
// The dot never covers the face or the rim; it lands exactly in the punched opening
.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%);
}
// The status dot — a 10px circle seated inside the mask hole (82% 82%, about 21px across)
.pd__dot {
position: absolute;
left: 82%;
top: 82%;
width: 10px;
height: 10px;
border-radius: 50%;
transform: translate(-50%, -50%);
}
// The state word lives in the HTML, not in the code — it is read back from the page
function currentLabel() {
var el = root.querySelector('.pd__state span[data-key="' + root.getAttribute('data-state') + '"]');
return el ? el.textContent : '';
}
// The aria-label is built as one finished sentence up front, so no stray gap appears mid-phrase
function applyLabel() {
wrap.setAttribute('aria-label', nameEl.textContent + ', ' + currentLabel());
}
07Mention chip input
What you see
Typing the at sign opens a list with avatars, the arrow keys walk it, and the chosen person lands as a chip in front of the field with a remove button to take it back out. In the loop a sweep bar travels 72px down the four rows and returns.
<main class="stage">
<section class="mc is-demo" aria-label="Mention input demo">
<div class="mc__row">
<!-- 이미 들어가 있는 칩 한 개. 새 칩은 사람이 실제로 골랐을 때만 이 자리에 끼워진다 -->
<span class="mc__chip">
<span class="mc__av mc__av--slate" aria-hidden="true"></span>
<svg class="mc__at" viewBox="0 0 256 256" width="12" height="12" aria-hidden="true" focusable="false" fill="currentColor"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M128,24a104,104,0,0,0,0,208c21.51,0,44.1-6.48,60.43-17.33a8,8,0,0,0-8.86-13.33C166,210.38,146.21,216,128,216a88,88,0,1,1,88-88c0,26.45-10.88,32-20,32s-20-5.55-20-32V88a8,8,0,0,0-16,0v4.26a48,48,0,1,0,5.93,65.1c6,12,16.35,18.64,30.07,18.64,22.54,0,36-17.94,36-48A104.11,104.11,0,0,0,128,24Zm0,136a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z"/></svg>
<span class="mc__name">Admin</span>
<button class="mc__x" type="button" aria-label="Remove mention">
<svg viewBox="0 0 256 256" width="10" height="10" aria-hidden="true" focusable="false" fill="currentColor"><path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity="0.2"/><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></svg>
</button>
</span>
<input class="mc__input" type="text" role="combobox" aria-expanded="true"
aria-controls="mc-list" aria-autocomplete="list" aria-activedescendant="mc-opt-0"
placeholder="Write a comment" autocomplete="off">
<!-- 절대배치 목록 — 줄(position:relative) 바로 아래에 열리고,
뿌리의 padding-bottom 이 열린 높이만큼 자리를 잡는다(top:100% 를 뿌리에 걸면
예약한 자리 '아래'로 내려가 320px 넘침이 생긴다 — 279 실측) -->
<ul class="mc__list" id="mc-list" role="listbox" aria-label="People to mention">
<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">Editor</span>
</li>
<li class="mc__opt" id="mc-opt-1" role="option" aria-selected="false">
<span class="mc__av mc__av--violet" aria-hidden="true"></span><span class="mc__name">Designer</span>
</li>
<li class="mc__opt" id="mc-opt-2" role="option" aria-selected="false">
<span class="mc__av mc__av--teal" aria-hidden="true"></span><span class="mc__name">Developer</span>
</li>
<li class="mc__opt" id="mc-opt-3" role="option" aria-selected="false">
<span class="mc__av mc__av--plum" aria-hidden="true"></span><span class="mc__name">Planner</span>
</li>
<li class="mc__bar" aria-hidden="true"></li>
</ul>
</div>
<template id="mc-tpl">
<span class="mc__chip">
<span class="mc__av" aria-hidden="true"></span>
<svg class="mc__at" viewBox="0 0 256 256" width="12" height="12" aria-hidden="true" focusable="false" fill="currentColor"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M128,24a104,104,0,0,0,0,208c21.51,0,44.1-6.48,60.43-17.33a8,8,0,0,0-8.86-13.33C166,210.38,146.21,216,128,216a88,88,0,1,1,88-88c0,26.45-10.88,32-20,32s-20-5.55-20-32V88a8,8,0,0,0-16,0v4.26a48,48,0,1,0,5.93,65.1c6,12,16.35,18.64,30.07,18.64,22.54,0,36-17.94,36-48A104.11,104.11,0,0,0,128,24Zm0,136a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z"/></svg>
<span class="mc__name"></span>
<button class="mc__x" type="button" aria-label="Remove mention">
<svg viewBox="0 0 256 256" width="10" height="10" aria-hidden="true" focusable="false" fill="currentColor"><path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity="0.2"/><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"/></svg>
</button>
</span>
</template>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 279 아바타 팔레트 — 이니셜 글자는 언제나 $subject-cream. 괄호는 크림 글자 기준 실측 대비비
$av-blue: #7c90be; // 5.29:1
$av-violet: #9791c9; // 5.76:1
$av-teal: #479082; // 5.79:1
$av-plum: #ab5488; // 6.52:1
$av-slate: #3b4a63; // 8.40:1
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 짚는 막대가 네 줄을 훑고 돌아오는 한 바퀴
$easing: $ease-out; // 줄 사이를 부드럽게 지난다
$color: $av-blue; // @ 표시·입력 초점 테두리
@include stage($stage-orange);
.mc {
position: relative;
box-sizing: border-box;
width: min(340px, 100%);
// 절대배치 목록은 부모 높이 계산에서 빠진다 — 열린 높이만큼 뿌리에 자리를 미리 잡는다(237 실측)
padding-bottom: calc(#{$sp-6} * 4 + #{$sp-1} * 3);
}
.mc__row {
position: relative;
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $sp-2;
min-height: $sp-10;
padding: $sp-2;
border-radius: $r-sm;
background: $subject-cream; // 오렌지 무대 위 밝은 면 — 글자는 잉크
box-shadow: $shadow-raised;
}
.mc__chip {
display: inline-flex;
align-items: center;
gap: $sp-1;
height: $sp-6;
padding: 0 $sp-1;
border-radius: $r-pill;
background: rgba($stage-ink, .08);
color: $stage-ink;
font-weight: 600;
font-size: 12px;
line-height: 1;
}
.mc__at { flex: none; color: $color; }
.mc__x {
display: grid;
place-items: center;
flex: none;
width: $sp-4;
height: $sp-4;
margin: 0;
padding: 0;
border: 0;
border-radius: $r-xs;
background: transparent;
color: $stage-ink;
cursor: pointer;
}
.mc__x:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
.mc__av {
display: grid;
place-items: center;
flex: none;
width: $sp-4;
height: $sp-4;
border-radius: 50%;
background: $av-slate;
color: $subject-cream;
font-weight: 700;
font-size: 10px;
line-height: 1;
}
.mc__av--blue { background: $av-blue; }
.mc__av--violet { background: $av-violet; }
.mc__av--teal { background: $av-teal; }
.mc__av--plum { background: $av-plum; }
.mc__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
color: $stage-ink;
font-family: inherit;
font-weight: 500;
font-size: 13px;
line-height: 1.2;
}
.mc__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 루프 촬영 중에는 안내 문구가 겹쳐 보이지 않게 한다
.mc.is-demo .mc__input::placeholder { color: transparent; }
.mc__list {
position: absolute;
top: calc(100% + #{$sp-1});
left: 0;
right: 0;
margin: 0;
padding: $sp-1;
list-style: none;
border-radius: $r-sm;
background: $subject-cream;
box-shadow: $shadow-raised;
}
.mc__opt {
position: relative;
display: flex;
align-items: center;
gap: $sp-2;
height: $sp-6;
padding: 0 $sp-2;
border-radius: $r-xs;
color: $stage-ink;
font-weight: 500;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.mc__opt[hidden] { display: none; }
// 실제 조작에서의 짚은 줄 표시 — 루프에서는 .mc__bar 가 이 역할을 대신한다
.mc:not(.is-demo) .mc__opt[aria-selected="true"] { background: rgba($stage-ink, .1); }
// ── 격자 미리보기 루프 ────────────────────────────────
// 목록은 루프 내내 열려 있고, 짚는 막대가 네 줄을 연속 transform 으로 훑고 돌아온다.
// 칩이 생겼다 사라지는 연출은 넣지 않는다 — 퇴장 프레임이 poster 로 뽑히기 때문이다.
// 칩 삽입은 사람이 실제로 조작할 때만 일어난다.
.mc__bar {
position: absolute;
left: $sp-1;
right: $sp-1;
top: $sp-1;
height: $sp-6;
border-radius: $r-xs;
background: rgba($stage-ink, .14);
pointer-events: none;
}
.mc:not(.is-demo) .mc__bar { visibility: hidden; }
.mc.is-demo .mc__bar {
animation-name: mcBar;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 한 바퀴를 86% 안에 끝낸다 — 24프레임의 마지막 장이 95.8% 지점이라 그 뒤는 찍히지 않는다
@keyframes mcBar {
0% { transform: translateY(0); }
36% { transform: translateY(72px); }
54% { transform: translateY(72px); }
86%, 100% { transform: translateY(0); }
}
// 좁은 화면 — 줄 높이(막대 이동거리 72px 와 묶여 있다)는 그대로 두고 여백만 줄인다
@media (max-width: 340px), (max-height: 220px) {
.mc { width: 100%; }
.mc__row { min-height: $sp-8; }
.mc__chip { height: $sp-5; }
.mc__input { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
// .mc.is-demo .mc__bar (0,3,0) 를 이기려면 !important. 막대는 첫 줄에 멈춰 보인다
.mc__bar { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mc {
position: relative;
box-sizing: border-box;
width: min(340px, 100%);
padding-bottom: calc(24px * 4 + 4px * 3);
}
.mc__row {
position: relative;
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 8px;
border-radius: 8px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.mc__chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-weight: 600;
font-size: 12px;
line-height: 1;
}
.mc__at {
flex: none;
color: #7c90be;
}
.mc__x {
display: grid;
place-items: center;
flex: none;
width: 16px;
height: 16px;
margin: 0;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: #17141a;
cursor: pointer;
}
.mc__x:focus-visible {
outline: 2px solid #7c90be;
outline-offset: 1px;
}
.mc__av {
display: grid;
place-items: center;
flex: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #3b4a63;
color: #fff7e6;
font-weight: 700;
font-size: 10px;
line-height: 1;
}
.mc__av--blue {
background: #7c90be;
}
.mc__av--violet {
background: #9791c9;
}
.mc__av--teal {
background: #479082;
}
.mc__av--plum {
background: #ab5488;
}
.mc__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
color: #17141a;
font-family: inherit;
font-weight: 500;
font-size: 13px;
line-height: 1.2;
}
.mc__input:focus-visible {
outline: 2px solid #7c90be;
outline-offset: 2px;
}
.mc.is-demo .mc__input::placeholder {
color: transparent;
}
.mc__list {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
margin: 0;
padding: 4px;
list-style: none;
border-radius: 8px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.mc__opt {
position: relative;
display: flex;
align-items: center;
gap: 8px;
height: 24px;
padding: 0 8px;
border-radius: 4px;
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.mc__opt[hidden] {
display: none;
}
.mc:not(.is-demo) .mc__opt[aria-selected=true] {
background: rgba(23, 20, 26, 0.1);
}
.mc__bar {
position: absolute;
left: 4px;
right: 4px;
top: 4px;
height: 24px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.14);
pointer-events: none;
}
.mc:not(.is-demo) .mc__bar {
visibility: hidden;
}
.mc.is-demo .mc__bar {
animation-name: mcBar;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes mcBar {
0% {
transform: translateY(0);
}
36% {
transform: translateY(72px);
}
54% {
transform: translateY(72px);
}
86%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px), (max-height: 220px) {
.mc {
width: 100%;
}
.mc__row {
min-height: 32px;
}
.mc__chip {
height: 20px;
}
.mc__input {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.mc__bar {
animation: none !important;
}
}(function () {
var root = document.querySelector('.mc');
var input = root.querySelector('.mc__input');
var list = root.querySelector('.mc__list');
var opts = [].slice.call(list.querySelectorAll('.mc__opt'));
var tpl = document.getElementById('mc-tpl');
var active = 0;
// 이니셜은 표시 문자열에서 계산한다(하드코딩 금지) — 언어팩이 바뀌면 이니셜도 따라간다
function initialOf(name) {
var p = name.trim().split(/\s+/);
return /[A-Za-z]/.test(p[0]) ? p.slice(0, 2).map(function (w) { return w.charAt(0).toUpperCase(); }).join('') : p[0].charAt(0);
}
[].forEach.call(root.querySelectorAll('.mc__chip, .mc__opt'), function (el) {
el.querySelector('.mc__av').textContent = initialOf(el.querySelector('.mc__name').textContent);
});
function visible() { return opts.filter(function (o) { return !o.hidden; }); }
function open() { list.hidden = false; input.setAttribute('aria-expanded', 'true'); }
function close() { list.hidden = true; input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant'); }
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);
}
// @ 를 치면 목록이 열리고, 이어 치는 글자로 이름을 걸러낸다
input.addEventListener('input', function () {
var at = input.value.lastIndexOf('@');
if (at < 0) { close(); return; }
open();
var q = input.value.slice(at + 1);
opts.forEach(function (o) { o.hidden = !o.querySelector('.mc__name').textContent.includes(q); });
setActive(0);
});
input.addEventListener('keydown', function (e) {
if (list.hidden) return;
if (e.key === 'ArrowDown') { setActive(active + 1); e.preventDefault(); }
else if (e.key === 'ArrowUp') { setActive(active - 1); e.preventDefault(); }
else if (e.key === 'Enter') { pick(visible()[active]); e.preventDefault(); }
else if (e.key === 'Escape') { close(); }
});
// 고른 사람을 칩으로 입력칸 앞에 넣고 @질의를 지운다
function pick(opt) {
if (!opt) return;
var chip = tpl.content.firstElementChild.cloneNode(true);
chip.querySelector('.mc__av').className = opt.querySelector('.mc__av').className;
chip.querySelector('.mc__av').textContent = opt.querySelector('.mc__av').textContent;
chip.querySelector('.mc__name').textContent = opt.querySelector('.mc__name').textContent;
root.querySelector('.mc__row').insertBefore(chip, input);
input.value = '';
close();
}
list.addEventListener('click', function (e) { var li = e.target.closest('.mc__opt'); if (li) pick(li); });
root.addEventListener('click', function (e) { var btn = e.target.closest('.mc__x'); if (btn) btn.closest('.mc__chip').remove(); });
// 사람이 손을 대면 미리보기 루프를 뗀다
function stopLoop() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./AvatarMentionChip.module.scss";
export type AvatarMentionChipProps = {
label?: string;
/** 목록에 올 사람(직무 명사) */
people?: string[];
/** 처음부터 들어가 있는 칩 */
initialChips?: string[];
placeholder?: string;
listLabel?: string;
removeLabel?: string;
/** 짚는 막대가 네 줄을 훑고 돌아오는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** @ 표시·입력 초점 테두리 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type InputEvt = { target: { value: string } };
type KeyEvt = { key: string; preventDefault(): void };
// 이니셜은 표시 문자열에서 계산한다(하드코딩 금지) — 언어팩이 바뀌면 이니셜도 따라간다
function initialOf(name: string): string {
const p = name.trim().split(/\s+/);
return /[A-Za-z]/.test(p[0]) ? p.slice(0, 2).map((w) => w.charAt(0).toUpperCase()).join("") : p[0].charAt(0);
}
const AtIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="12" height="12" aria-hidden="true" focusable="false" fill="currentColor" className={styles.at}>
<path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2" />
<path d="M128,24a104,104,0,0,0,0,208c21.51,0,44.1-6.48,60.43-17.33a8,8,0,0,0-8.86-13.33C166,210.38,146.21,216,128,216a88,88,0,1,1,88-88c0,26.45-10.88,32-20,32s-20-5.55-20-32V88a8,8,0,0,0-16,0v4.26a48,48,0,1,0,5.93,65.1c6,12,16.35,18.64,30.07,18.64,22.54,0,36-17.94,36-48A104.11,104.11,0,0,0,128,24Zm0,136a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z" />
</svg>
);
const XIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="10" height="10" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M216,56V200a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V56A16,16,0,0,1,56,40H200A16,16,0,0,1,216,56Z" opacity="0.2" />
<path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z" />
</svg>
);
export default function AvatarMentionChip({
label = "멘션 입력 시범",
people = ["편집자", "디자이너", "개발자", "기획자"],
initialChips = ["관리자"],
placeholder = "댓글을 입력하세요",
listLabel = "멘션할 사람",
removeLabel = "멘션 지우기",
duration = 2000,
easing = "ease-out",
color = "#2a5fd8",
}: AvatarMentionChipProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const [value, setValue] = useState<string>("");
const [open, setOpen] = useState<boolean>(true);
const [active, setActive] = useState<number>(0);
const [chips, setChips] = useState<string[]>(initialChips);
// @ 를 치면 목록이 열리고, 이어 치는 글자로 이름을 걸러낸다
const at = value.lastIndexOf("@");
const visible = at < 0 ? [] : people.filter((n) => n.includes(value.slice(at + 1)));
const activeName = visible.length ? visible[Math.min(active, visible.length - 1)] : "";
const toneClasses: Record<string, string> = {
blue: styles.avBlue,
violet: styles.avViolet,
teal: styles.avTeal,
plum: styles.avPlum,
slate: styles.avSlate,
};
// 아바타 배경은 목록 순서로 붙고, 목록 밖 사람은 슬레이트
function toneOf(name: string): string {
const i = people.indexOf(name);
return i < 0 ? "slate" : ["blue", "violet", "teal", "plum", "slate"][i % 5];
}
function move(d: number) {
if (!visible.length) return;
setActive((a) => (a + d + visible.length) % visible.length);
}
// 고른 사람을 칩으로 입력칸 앞에 넣고 @질의를 지운다
function pick(name: string) {
setChips((c) => c.concat(name));
setValue("");
setOpen(false);
}
function onChange(e: InputEvt) {
setValue(e.target.value);
if (e.target.value.lastIndexOf("@") < 0) { setOpen(false); return; }
setOpen(true);
setActive(0);
}
function onKeyDown(e: KeyEvt) {
if (!open) return;
if (e.key === "ArrowDown") { e.preventDefault(); move(1); }
else if (e.key === "ArrowUp") { e.preventDefault(); move(-1); }
else if (e.key === "Enter") { e.preventDefault(); if (activeName) pick(activeName); }
else if (e.key === "Escape") { setOpen(false); }
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section
className={loop ? `${styles.mc} ${styles.isDemo}` : styles.mc}
style={vars}
aria-label={label}
onPointerDown={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
<div className={styles.row}>
{chips.map((name) => (
<span className={styles.chip} key={name}>
<span className={`${styles.av} ${toneClasses[toneOf(name)]}`} aria-hidden="true">{initialOf(name)}</span>
<AtIcon />
<span className={styles.name}>{name}</span>
<button
className={styles.x}
type="button"
aria-label={removeLabel}
onClick={() => setChips((c) => c.filter((n) => n !== name))}
>
<XIcon />
</button>
</span>
))}
<input
className={styles.input}
type="text"
role="combobox"
aria-expanded={open ? "true" : "false"}
aria-controls="mc-list"
aria-autocomplete="list"
aria-activedescendant={activeName ? `mc-opt-${people.indexOf(activeName)}` : undefined}
placeholder={placeholder}
autoComplete="off"
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
/>
{/* 절대배치 목록 — 줄(position:relative) 바로 아래에 열리고 뿌리의 padding-bottom 이 자리를 잡는다 */}
<ul className={styles.list} id="mc-list" role="listbox" aria-label={listLabel} hidden={!open}>
{visible.map((name) => (
<li
key={name}
id={`mc-opt-${people.indexOf(name)}`}
role="option"
aria-selected={name === activeName ? "true" : "false"}
className={styles.opt}
onClick={() => pick(name)}
>
<span className={`${styles.av} ${toneClasses[toneOf(name)]}`} aria-hidden="true">{initialOf(name)}</span>
<span className={styles.name}>{name}</span>
</li>
))}
{/* 짚는 막대 — 격자 미리보기 루프에서만 움직인다 */}
<li className={styles.bar} aria-hidden="true" />
</ul>
</div>
</section>
);
}07 · Mention chip input — Measured area 21.972% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Use it in comment and task fields that call people out by name.
<input class="mc__input" type="text" role="combobox" aria-expanded="true"
aria-controls="mc-list" aria-autocomplete="list" aria-activedescendant="mc-opt-0"
placeholder="Write a comment" autocomplete="off">
<ul class="mc__list" id="mc-list" role="listbox" aria-label="People to mention">
<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">Editor</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);
}
08Hover user card
What happens on screen
The card rises only after the pointer has rested on the avatar for 300ms, and it waits 200ms before closing, so it survives the handoff while the pointer travels onto it. The entrance is a single-frame cut at 26% followed by a short pop, with the activity bar filling from 28 to 84%.
<main class="stage">
<section class="uc is-demo" aria-label="User card demo">
<!-- 가짜 포인터 — 미리보기 루프에서만 보인다 -->
<span class="uc__pointer" aria-hidden="true"></span>
<p class="uc__line">
<span class="uc__ago">Just now</span>
<button class="uc__trigger" type="button" aria-describedby="uc-card">
<span class="uc__av" aria-hidden="true"></span>
<span class="uc__name">Editor</span>
</button>
<span class="uc__rest">fixed a document</span>
</p>
<!-- 절대배치 카드 — 뿌리의 padding-bottom 이 열린 높이만큼 자리를 잡는다 -->
<div class="uc__card" id="uc-card" role="tooltip">
<div class="uc__head">
<span class="uc__bigav" aria-hidden="true"></span>
<p class="uc__who">
<span class="uc__cardName">Editor</span>
<span class="uc__job">Document editor</span>
</p>
</div>
<div class="uc__stats">
<p class="uc__stat"><span class="uc__val">128</span><span class="uc__lab">Docs edited</span></p>
<p class="uc__stat"><span class="uc__val">36</span><span class="uc__lab">Thanks received</span></p>
</div>
<span class="uc__fill" aria-hidden="true"><span class="uc__fillIn"></span></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 279 아바타 팔레트(쓰는 색만) — 이니셜 글자는 언제나 $subject-cream
$av-blue: #7c90be; // 5.29:1 (크림 글자 기준)
$av-deep: #4a4452; // 페이퍼 무대 보조 글자 9.38:1
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 포인터가 다가가 카드가 열리고 닫히는 한 바퀴
$easing: $ease-out; // 포인터 이동·카드 자리 잡기
$color: $av-blue; // 아바타·초점 테두리·채우는 막대·포인터
@include stage($stage-paper);
.uc {
position: relative;
box-sizing: border-box;
width: min(300px, 100%);
// 포인터가 다가오는 공간 + 절대배치 카드의 열린 높이(237 실측 — 세로 예약 없으면 BD9 가 터진다)
padding-top: $sp-6;
padding-bottom: calc(#{$sp-12} * 2 + #{$sp-3});
}
.uc__line {
position: relative;
display: flex;
align-items: center;
margin: 0;
color: $stage-ink;
font-weight: 500;
font-size: 13px;
line-height: 1.2;
}
.uc__ago { flex: none; margin-right: $sp-1; color: $av-deep; }
.uc__rest { margin-left: $sp-1; }
.uc__trigger {
display: inline-flex;
align-items: center;
flex: none;
gap: $sp-1;
margin: 0;
padding: $sp-1 $sp-2;
border: 0;
border-radius: $r-pill;
background: transparent;
color: $stage-ink;
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1.2;
cursor: pointer;
}
.uc__trigger:hover { background: rgba($stage-ink, .06); }
.uc__trigger:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.uc__av, .uc__bigav {
display: grid;
place-items: center;
flex: none;
border-radius: 50%;
background: $av-blue;
color: $subject-cream;
font-weight: 700;
line-height: 1;
}
.uc__av { width: $sp-6; height: $sp-6; font-size: 11px; }
.uc__bigav { width: $sp-8; height: $sp-8; font-size: 14px; }
// ── 사용자 카드(정적 상태 — 루프가 없어도 열려 있는 모습이 그대로 보인다) ──
.uc__card {
position: absolute;
top: calc(#{$sp-6} + #{$sp-8} + #{$sp-1}); // 포인터 공간 24 + 줄 높이 32 + 간격 4
left: 0;
box-sizing: border-box;
display: grid;
gap: $sp-2;
width: calc(#{$sp-12} * 4);
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
}
.uc__card[hidden] { display: none; }
.uc__head { display: flex; align-items: center; gap: $sp-2; }
.uc__who { display: grid; gap: $sp-1; margin: 0; }
.uc__cardName { color: $stage-ink; font-weight: 700; font-size: 14px; line-height: 1.2; }
.uc__job { color: $av-deep; font-weight: 500; font-size: 11px; line-height: 1.2; }
.uc__stats { display: flex; gap: $sp-4; }
.uc__stat { display: grid; gap: $sp-1; margin: 0; }
.uc__val { color: $stage-ink; font-weight: 700; font-size: 15px; line-height: 1; }
.uc__lab { color: $av-deep; font-weight: 500; font-size: 10px; line-height: 1; }
.uc__fill {
display: block;
height: $sp-1;
border-radius: $r-pill;
background: rgba($stage-ink, .12);
overflow: hidden;
}
.uc__fillIn {
display: block;
height: 100%;
border-radius: inherit;
background: $color;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 통계 막대 왼쪽 채움
}
// 가짜 포인터 — 실제 조용한 화면에서는 감춘다
.uc__pointer {
position: absolute;
top: 0;
left: $sp-1;
width: $sp-3;
height: $sp-3;
border-radius: 50%;
background: $color;
box-shadow: 0 0 0 $sp-1 $stage-paper;
pointer-events: none;
}
.uc:not(.is-demo) .uc__pointer { visibility: hidden; }
// ── 격자 미리보기 루프 ────────────────────────────────
// 포인터가 트리거로 미끄러지고(0~26%), 도착 순간 카드가 steps(1,end) 컷으로 한 프레임에 나타난다.
// 컷 직후 translateY 6px→0 팝이 프레임을 채우고 28~84% 는 막대가 scaleX 로 차오른다.
// 닫힘(84~92%)은 clip-path 위프로 여러 프레임에 면적을 나눠 줄인다 — 열림 컷이 언제나 최대 변화가
// 되어 poster 가 '카드가 떠 있는 장면'이 된다(266 실측 계열). 복귀는 92% 안에 끝난다.
// 실제 사용자에게는 카드가 타이머(300ms 지연·200ms 유예)로 열리고 닫힌다 — 루프는 그 요약이다.
.uc.is-demo .uc__card {
animation-name: ucCard, ucWipe;
animation-duration: $duration, $duration;
animation-timing-function: $easing, ease-in;
animation-iteration-count: infinite, infinite;
}
.uc.is-demo .uc__fillIn {
animation-name: ucFill;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.uc.is-demo .uc__pointer {
animation-name: ucPointer;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@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); }
}
@keyframes ucWipe {
0%, 84% { clip-path: inset(0 0 0 0); animation-timing-function: ease-in; }
92%, 100% { clip-path: inset(0 0 100% 0); }
}
@keyframes ucFill {
0%, 28% { transform: scaleX(0); }
84%, 100% { transform: scaleX(1); }
}
@keyframes ucPointer {
0% { translate: 0 0; }
26% { translate: 64px 22px; }
40% { translate: 64px 26px; }
54% { translate: 64px 22px; }
84% { translate: 64px 22px; }
92%, 100% { translate: 0 0; }
}
// 좁은 화면 — 포인터 공간만 줄인다. 카드·줄 크기는 그대로(포인터 키프레임 px 와 묶여 있다)
@media (max-width: 340px), (max-height: 220px) {
.uc { padding-top: $sp-4; }
.uc__line { font-size: 12px; }
.uc__trigger { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
// .uc.is-demo .uc__card (0,3,0) 를 이기려면 !important. 카드는 열린 채 멈춰 보인다
.uc__card { animation: none !important; }
.uc__fillIn { animation: none !important; transform: scaleX(1) !important; }
.uc__pointer { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.uc {
position: relative;
box-sizing: border-box;
width: min(300px, 100%);
padding-top: 24px;
padding-bottom: calc(48px * 2 + 12px);
}
.uc__line {
position: relative;
display: flex;
align-items: center;
margin: 0;
color: #17141a;
font-weight: 500;
font-size: 13px;
line-height: 1.2;
}
.uc__ago {
flex: none;
margin-right: 4px;
color: #4a4452;
}
.uc__rest {
margin-left: 4px;
}
.uc__trigger {
display: inline-flex;
align-items: center;
flex: none;
gap: 4px;
margin: 0;
padding: 4px 8px;
border: 0;
border-radius: 999px;
background: transparent;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1.2;
cursor: pointer;
}
.uc__trigger:hover {
background: rgba(23, 20, 26, 0.06);
}
.uc__trigger:focus-visible {
outline: 2px solid #7c90be;
outline-offset: 2px;
}
.uc__av, .uc__bigav {
display: grid;
place-items: center;
flex: none;
border-radius: 50%;
background: #7c90be;
color: #fff7e6;
font-weight: 700;
line-height: 1;
}
.uc__av {
width: 24px;
height: 24px;
font-size: 11px;
}
.uc__bigav {
width: 32px;
height: 32px;
font-size: 14px;
}
.uc__card {
position: absolute;
top: calc(24px + 32px + 4px);
left: 0;
box-sizing: border-box;
display: grid;
gap: 8px;
width: calc(48px * 4);
padding: 12px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.uc__card[hidden] {
display: none;
}
.uc__head {
display: flex;
align-items: center;
gap: 8px;
}
.uc__who {
display: grid;
gap: 4px;
margin: 0;
}
.uc__cardName {
color: #17141a;
font-weight: 700;
font-size: 14px;
line-height: 1.2;
}
.uc__job {
color: #4a4452;
font-weight: 500;
font-size: 11px;
line-height: 1.2;
}
.uc__stats {
display: flex;
gap: 16px;
}
.uc__stat {
display: grid;
gap: 4px;
margin: 0;
}
.uc__val {
color: #17141a;
font-weight: 700;
font-size: 15px;
line-height: 1;
}
.uc__lab {
color: #4a4452;
font-weight: 500;
font-size: 10px;
line-height: 1;
}
.uc__fill {
display: block;
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
overflow: hidden;
}
.uc__fillIn {
display: block;
height: 100%;
border-radius: inherit;
background: #7c90be;
transform: scaleX(0);
transform-origin: left center;
}
.uc__pointer {
position: absolute;
top: 0;
left: 4px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #7c90be;
box-shadow: 0 0 0 4px #ffffff;
pointer-events: none;
}
.uc:not(.is-demo) .uc__pointer {
visibility: hidden;
}
.uc.is-demo .uc__card {
animation-name: ucCard, ucWipe;
animation-duration: 2s, 2s;
animation-timing-function: ease-out, ease-in;
animation-iteration-count: infinite, infinite;
}
.uc.is-demo .uc__fillIn {
animation-name: ucFill;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.uc.is-demo .uc__pointer {
animation-name: ucPointer;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes ucCard {
0% {
opacity: 0;
transform: translateY(6px);
animation-timing-function: steps(1, end);
}
26% {
opacity: 1;
transform: translateY(6px);
animation-timing-function: ease-out;
}
34%, 100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes ucWipe {
0%, 84% {
clip-path: inset(0 0 0 0);
animation-timing-function: ease-in;
}
92%, 100% {
clip-path: inset(0 0 100% 0);
}
}
@keyframes ucFill {
0%, 28% {
transform: scaleX(0);
}
84%, 100% {
transform: scaleX(1);
}
}
@keyframes ucPointer {
0% {
translate: 0 0;
}
26% {
translate: 64px 22px;
}
40% {
translate: 64px 26px;
}
54% {
translate: 64px 22px;
}
84% {
translate: 64px 22px;
}
92%, 100% {
translate: 0 0;
}
}
@media (max-width: 340px), (max-height: 220px) {
.uc {
padding-top: 16px;
}
.uc__line {
font-size: 12px;
}
.uc__trigger {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.uc__card {
animation: none !important;
}
.uc__fillIn {
animation: none !important;
transform: scaleX(1) !important;
}
.uc__pointer {
animation: none !important;
}
}(function () {
var root = document.querySelector('.uc');
var trigger = root.querySelector('.uc__trigger');
var card = root.querySelector('.uc__card');
var openT = 0, closeT = 0;
// 호버 — 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);
// 초점 — 지연 없이 열리고, blur·Escape 으로 닫힌다
trigger.addEventListener('focus', function () { clearTimeout(closeT); card.hidden = false; });
trigger.addEventListener('blur', function () { card.hidden = true; });
trigger.addEventListener('keydown', function (e) { if (e.key === 'Escape') card.hidden = true; });
// 이니셜은 표시 문자열에서 계산한다(하드코딩 금지) — 언어팩이 바뀌면 이니셜도 따라간다
function initialOf(name) {
var p = name.trim().split(/\s+/);
return /[A-Za-z]/.test(p[0]) ? p.slice(0, 2).map(function (w) { return w.charAt(0).toUpperCase(); }).join('') : p[0].charAt(0);
}
var name = root.querySelector('.uc__name').textContent;
trigger.querySelector('.uc__av').textContent = initialOf(name);
root.querySelector('.uc__bigav').textContent = initialOf(root.querySelector('.uc__cardName').textContent);
// 사람이 손을 대면 미리보기 루프를 뗀다
function stopLoop() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./AvatarHoverUserCard.module.scss";
export type AvatarHoverUserCardProps = {
label?: string;
name?: string;
ago?: string;
rest?: string;
job?: string;
statALabel?: string;
statAValue?: string;
statBLabel?: string;
statBValue?: string;
/** 포인터가 다가가 카드가 열리고 닫히는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 아바타·초점 테두리·채우는 막대·포인터 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type KeyEvt = { key: string };
// 이니셜은 표시 문자열에서 계산한다(하드코딩 금지) — 언어팩이 바뀌면 이니셜도 따라간다
function initialOf(name: string): string {
const p = name.trim().split(/\s+/);
return /[A-Za-z]/.test(p[0]) ? p.slice(0, 2).map((w) => w.charAt(0).toUpperCase()).join("") : p[0].charAt(0);
}
export default function AvatarHoverUserCard({
label = "사용자 카드 안내",
name = "편집자",
ago = "방금",
rest = "님이 문서를 고쳤습니다",
job = "문서 편집 담당",
statALabel = "고친 문서",
statAValue = "128",
statBLabel = "받은 감사",
statBValue = "36",
duration = 2000,
easing = "ease-out",
color = "#2a5fd8",
}: AvatarHoverUserCardProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const [open, setOpen] = useState<boolean>(true);
const openT = useRef(0) as Box<number>;
const closeT = useRef(0) as Box<number>;
useEffect(() => () => { window.clearTimeout(openT.current); window.clearTimeout(closeT.current); }, []);
// 호버 — 300ms 지연 뒤 열리고, 벗어나면 200ms 유예 뒤 닫힌다.
// 유예 동안 카드 위로 옮겨 가면 닫히지 않는다(카드에도 같은 핸들러)
function enter() {
window.clearTimeout(openT.current);
window.clearTimeout(closeT.current);
openT.current = window.setTimeout(() => setOpen(true), 300);
}
function leave() {
window.clearTimeout(openT.current);
window.clearTimeout(closeT.current);
closeT.current = window.setTimeout(() => setOpen(false), 200);
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section
className={loop ? `${styles.uc} ${styles.isDemo}` : styles.uc}
style={vars}
aria-label={label}
onPointerDown={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
{/* 가짜 포인터 — 미리보기 루프에서만 보인다 */}
<span className={styles.pointer} aria-hidden="true" />
<p className={styles.line}>
<span className={styles.ago}>{ago}</span>
<button
className={styles.trigger}
type="button"
aria-describedby="uc-card"
onPointerEnter={enter}
onPointerLeave={leave}
/* 초점 — 지연 없이 열리고, blur·Escape 으로 닫힌다 */
onFocus={() => { window.clearTimeout(closeT.current); setOpen(true); }}
onBlur={() => setOpen(false)}
onKeyDown={(e: KeyEvt) => { if (e.key === "Escape") setOpen(false); }}
>
<span className={styles.av} aria-hidden="true">{initialOf(name)}</span>
<span className={styles.name}>{name}</span>
</button>
<span className={styles.rest}>{rest}</span>
</p>
<div
className={styles.card}
id="uc-card"
role="tooltip"
hidden={!open}
onPointerEnter={() => window.clearTimeout(closeT.current)}
onPointerLeave={leave}
>
<div className={styles.head}>
<span className={styles.bigav} aria-hidden="true">{initialOf(name)}</span>
<p className={styles.who}>
<span className={styles.cardName}>{name}</span>
<span className={styles.job}>{job}</span>
</p>
</div>
<div className={styles.stats}>
<p className={styles.stat}><span className={styles.val}>{statAValue}</span><span className={styles.lab}>{statALabel}</span></p>
<p className={styles.stat}><span className={styles.val}>{statBValue}</span><span className={styles.lab}>{statBLabel}</span></p>
</div>
<span className={styles.fill} aria-hidden="true"><span className={styles.fillIn} /></span>
</div>
</section>
);
}08 · Hover user card — Measured area 15.22% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Use it where only a name shows and a reader needs to check who it is.
// Opening waits 300ms — a grazing pass never shakes the card loose.
// Closing holds a 200ms grace — the card stays alive while the pointer crosses onto it
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);
// The card entrance is a cut plus a short pop — no translucent ghost frame appears
@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); }
}
09Photo positioning
What moves
The picture drags under a round window and a slider raises the zoom, while the allowed drag range narrows first so lowering the zoom never exposes a gap at the window edge. The range is maxOffset = (w × 1.5 × z − w) / 2, recomputed against the live window size.
<main class="stage">
<section class="cu is-demo" aria-label="Profile photo positioning">
<!-- 테두리와 창은 제자리 — 움직이는 것은 그 안의 사진뿐이다. 사진 파일은 쓰지 않는다(그라디언트 한 겹) -->
<div class="cu__frame">
<div class="cu__window" tabindex="0" aria-label="Drag the photo to position it">
<div class="cu__photo" aria-hidden="true"></div>
</div>
</div>
<div class="cu__controls">
<svg class="cu__cam" viewBox="0 0 256 256" width="20" height="20" aria-hidden="true" focusable="false" fill="currentColor"><path d="M208,64H176L160,40H96L80,64H48A16,16,0,0,0,32,80V192a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V80A16,16,0,0,0,208,64ZM128,168a36,36,0,1,1,36-36A36,36,0,0,1,128,168Z" opacity="0.2"/><path d="M208,56H180.28L166.65,35.56A8,8,0,0,0,160,32H96a8,8,0,0,0-6.65,3.56L75.71,56H48A24,24,0,0,0,24,80V192a24,24,0,0,0,24,24H208a24,24,0,0,0,24-24V80A24,24,0,0,0,208,56Zm8,136a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V80a8,8,0,0,1,8-8H80a8,8,0,0,0,6.66-3.56L100.28,48h55.43l13.63,20.44A8,8,0,0,0,176,72h32a8,8,0,0,1,8,8ZM128,88a44,44,0,1,0,44,44A44.05,44.05,0,0,0,128,88Zm0,72a28,28,0,1,1,28-28A28,28,0,0,1,128,160Z"/></svg>
<input class="cu__zoom" type="range" min="1" max="2" step="0.01" value="1" aria-label="Zoom level">
<span class="cu__preview" aria-hidden="true"><span class="cu__mini"></span></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 279 아바타 팔레트(쓰는 색만) — 사진 파일은 금지, 가짜 사진은 CSS 그라디언트 한 격으로 그린다
$av-violet: #9791c9; // 5.76:1 (크림 글자 기준)
$av-plum: #ab5488; // 6.52:1
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 사진이 이동·확대했다 되돌아오는 한 바퀴
$easing: $ease-out; // 느려지며 자리를 바꾼다
$color: $subject-lilac; // 카메라 아이콘·초점 테두리·슬라이더
@include stage($stage-ink);
.cu {
box-sizing: border-box;
display: grid;
justify-items: center;
gap: $sp-3;
width: min(208px, 100%);
}
.cu__frame {
position: relative;
width: 112px;
height: 112px;
border: 2px solid rgba($subject-cream, .4);
border-radius: 50%;
}
// 초점 표시는 클립되지 않는 테두리에 건다 — clip-path 는 outline 까지 잘라낸다
.cu__frame:focus-within { border-color: $color; box-shadow: 0 0 0 2px $color; }
.cu__window {
position: absolute;
inset: 0;
clip-path: circle(50%);
touch-action: none;
cursor: grab;
}
.cu__window:active { cursor: grabbing; }
// 사진 바탕은 창의 1.5배(inset -25%) — maxOffset = (1.5w × z − w) / 2 계산의 기준.
// JS 가 뿌리의 --x · --y · --z 를 바꾸면 사진과 미리보기가 함께 따라간다.
// 그라디언만으로는 이웃 화소끼리 차가 작아 10px 움직여도 강도가 문턱(15)을 못 넘는다(실측 13.8) —
// 밝은 얼굴 반점·어두운 어깨 반점 두 개를 겹쳐 가짜 사진의 경계를 뚜렷하게 만든다
%cu-photo-bg {
background:
radial-gradient(circle at 32% 26%, $subject-cream 0 17%, rgba($subject-cream, 0) 38%),
radial-gradient(circle at 72% 76%, $stage-ink 0 15%, rgba($stage-ink, 0) 36%),
linear-gradient(160deg, $subject-sky 0%, $subject-lilac 34%, $av-violet 62%, $av-plum 100%);
}
.cu__photo {
position: absolute;
inset: -25%;
@extend %cu-photo-bg;
transform: translate(var(--x, 0px), var(--y, 0px)) scale(var(--z, 1));
will-change: transform;
}
.cu__controls {
display: flex;
align-items: center;
gap: $sp-3;
}
.cu__cam { flex: none; color: $color; }
.cu__zoom {
width: 104px;
margin: 0;
accent-color: $color;
}
.cu__preview {
position: relative;
flex: none;
display: block;
width: $sp-10;
height: $sp-10;
overflow: hidden;
border: 2px solid rgba($subject-cream, .4);
border-radius: 50%;
}
// 결과 미리보기 — 같은 사진 바탕을 4분의 1 이동으로 미러한다
.cu__mini {
position: absolute;
inset: -25%;
@extend %cu-photo-bg;
transform: translate(calc(var(--x, 0px) * .25), calc(var(--y, 0px) * .25)) scale(var(--z, 1));
}
// ── 격자 미리보기 루프 ────────────────────────────────
// 사진이 천천히 이동·확대했다 되돌아온다(연속 transform, 텔레포트 없음). 창과 테두리는 제자리.
// 이동량은 언제나 maxOffset 안쪽 — 배율이 어떻더라도 창 가장자리에 빈 여백이 안 생긴다.
// 한 바퀴를 88% 안에 끝낸다 — 24프레임의 마지막 장이 95.8% 지점이라 그 뒤는 찍히지 않는다.
.cu.is-demo .cu__photo {
animation-name: cuPhoto;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cuPhoto {
0% { transform: translate(0, 0) scale(1); }
30% { transform: translate(-16px, 10px) scale(1.25); }
55% { transform: translate(14px, -12px) scale(1.6); }
78% { transform: translate(0, 6px) scale(1.9); }
88%, 100% { transform: translate(0, 0) scale(1); }
}
// 좁은 화면 — 창·미리보기만 줄인다. 이동·배율 범위는 JS 가 창 크기에서 다시 계산한다
@media (max-width: 340px), (max-height: 220px) {
.cu { gap: $sp-2; }
.cu__frame { width: 96px; height: 96px; }
.cu__zoom { width: 84px; }
.cu__preview { width: $sp-8; height: $sp-8; }
}
@media (prefers-reduced-motion: reduce) {
// .cu.is-demo .cu__photo (0,3,0) 를 이기려면 !important. 사진은 가운데에 멈춰 보인다
.cu__photo { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cu {
box-sizing: border-box;
display: grid;
justify-items: center;
gap: 12px;
width: min(208px, 100%);
}
.cu__frame {
position: relative;
width: 112px;
height: 112px;
border: 2px solid rgba(255, 247, 230, 0.4);
border-radius: 50%;
}
.cu__frame:focus-within {
border-color: #b9a0ff;
box-shadow: 0 0 0 2px #b9a0ff;
}
.cu__window {
position: absolute;
inset: 0;
clip-path: circle(50%);
touch-action: none;
cursor: grab;
}
.cu__window:active {
cursor: grabbing;
}
.cu__mini, .cu__photo {
background: radial-gradient(circle at 32% 26%, #fff7e6 0 17%, rgba(255, 247, 230, 0) 38%), radial-gradient(circle at 72% 76%, #17141a 0 15%, rgba(23, 20, 26, 0) 36%), linear-gradient(160deg, #6ec8ff 0%, #b9a0ff 34%, #9791c9 62%, #ab5488 100%);
}
.cu__photo {
position: absolute;
inset: -25%;
transform: translate(var(--x, 0px), var(--y, 0px)) scale(var(--z, 1));
will-change: transform;
}
.cu__controls {
display: flex;
align-items: center;
gap: 12px;
}
.cu__cam {
flex: none;
color: #b9a0ff;
}
.cu__zoom {
width: 104px;
margin: 0;
accent-color: #b9a0ff;
}
.cu__preview {
position: relative;
flex: none;
display: block;
width: 40px;
height: 40px;
overflow: hidden;
border: 2px solid rgba(255, 247, 230, 0.4);
border-radius: 50%;
}
.cu__mini {
position: absolute;
inset: -25%;
transform: translate(calc(var(--x, 0px) * 0.25), calc(var(--y, 0px) * 0.25)) scale(var(--z, 1));
}
.cu.is-demo .cu__photo {
animation-name: cuPhoto;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes cuPhoto {
0% {
transform: translate(0, 0) scale(1);
}
30% {
transform: translate(-16px, 10px) scale(1.25);
}
55% {
transform: translate(14px, -12px) scale(1.6);
}
78% {
transform: translate(0, 6px) scale(1.9);
}
88%, 100% {
transform: translate(0, 0) scale(1);
}
}
@media (max-width: 340px), (max-height: 220px) {
.cu {
gap: 8px;
}
.cu__frame {
width: 96px;
height: 96px;
}
.cu__zoom {
width: 84px;
}
.cu__preview {
width: 32px;
height: 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.cu__photo {
animation: none !important;
}
}(function () {
var root = document.querySelector('.cu');
var win = root.querySelector('.cu__window');
var zoom = root.querySelector('.cu__zoom');
var x = 0, y = 0, z = 1;
// 핵심 — 허용 이동 범위는 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);
}
// 끌기 — setPointerCapture 로 창 밖으로 나가도 놓치지 않는다
var drag = null;
win.addEventListener('pointerdown', function (e) {
drag = { id: e.pointerId, px: e.clientX, py: e.clientY };
win.setPointerCapture(e.pointerId);
});
win.addEventListener('pointermove', function (e) {
if (!drag || e.pointerId !== drag.id) return;
x = clamp(x + e.clientX - drag.px, z);
y = clamp(y + e.clientY - drag.py, z);
drag.px = e.clientX; drag.py = e.clientY;
apply();
});
win.addEventListener('pointerup', function (e) { if (drag && e.pointerId === drag.id) drag = null; });
win.addEventListener('pointercancel', function () { drag = null; });
// 배율이 바뀌면 현재 x·y 를 새 범위로 다시 물린다(clamp)
zoom.addEventListener('input', function () {
z = parseFloat(zoom.value);
x = clamp(x, z);
y = clamp(y, z);
apply();
});
// 키보드 — 화살표 키로 4px 씩 움직인다
win.addEventListener('keydown', function (e) {
var step = { ArrowLeft: [-4, 0], ArrowRight: [4, 0], ArrowUp: [0, -4], ArrowDown: [0, 4] }[e.key];
if (!step) return;
e.preventDefault();
x = clamp(x + step[0], z);
y = clamp(y + step[1], z);
apply();
});
// 사람이 손을 대면 미리보기 루프를 뗀다
function stopLoop() { root.classList.remove('is-demo'); }
root.addEventListener('pointerdown', stopLoop);
root.addEventListener('focusin', stopLoop);
})();import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./AvatarCropUpload.module.scss";
export type AvatarCropUploadProps = {
label?: string;
windowLabel?: string;
zoomLabel?: string;
/** 사진이 이동·확대했다 되돌아오는 한 바퀴(ms) */
duration?: number;
easing?: string;
/** 카메라 아이콘·초점 테두리·슬라이더 색 */
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--x"?: string;
"--y"?: string;
"--z"?: string;
};
type Box<T> = { current: T };
type PointerEvt = { clientX: number; clientY: number; pointerId: number; currentTarget: { setPointerCapture(id: number): void } };
type KeyEvt = { key: string; preventDefault(): void };
type RangeEvt = { target: { value: string } };
const ARROWS: Record<string, [number, number]> = {
ArrowLeft: [-4, 0],
ArrowRight: [4, 0],
ArrowUp: [0, -4],
ArrowDown: [0, 4],
};
const CameraIcon = (): ReactNode => (
<svg viewBox="0 0 256 256" width="20" height="20" aria-hidden="true" focusable="false" fill="currentColor" className={styles.cam}>
<path d="M208,64H176L160,40H96L80,64H48A16,16,0,0,0,32,80V192a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V80A16,16,0,0,0,208,64ZM128,168a36,36,0,1,1,36-36A36,36,0,0,1,128,168Z" opacity="0.2" />
<path d="M208,56H180.28L166.65,35.56A8,8,0,0,0,160,32H96a8,8,0,0,0-6.65,3.56L75.71,56H48A24,24,0,0,0,24,80V192a24,24,0,0,0,24,24H208a24,24,0,0,0,24-24V80A24,24,0,0,0,208,56Zm8,136a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V80a8,8,0,0,1,8-8H80a8,8,0,0,0,6.66-3.56L100.28,48h55.43l13.63,20.44A8,8,0,0,0,176,72h32a8,8,0,0,1,8,8ZM128,88a44,44,0,1,0,44,44A44.05,44.05,0,0,0,128,88Zm0,72a28,28,0,1,1,28-28A28,28,0,0,1,128,160Z" />
</svg>
);
export default function AvatarCropUpload({
label = "프로필 사진 위치 잡기",
windowLabel = "사진을 끌어 위치를 맞추는 창",
zoomLabel = "확대 배율",
duration = 2000,
easing = "ease-out",
color = "#b9a0ff",
}: AvatarCropUploadProps): ReactNode {
const [loop, setLoop] = useState<boolean>(true);
const [x, setX] = useState<number>(0);
const [y, setY] = useState<number>(0);
const [z, setZ] = useState<number>(1);
const win = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const drag = useRef<{ id: number; px: number; py: number } | null>(null) as Box<{ id: number; px: number; py: number } | null>;
// 핵심 — 허용 이동 범위는 maxOffset = (사진크기 × 배율 − 창크기) / 2.
// 배율을 줄이면 범위가 먼저 좁아지므로 창 가장자리에 빈 여백이 절대 생기지 않는다.
// 사진 바탕은 창의 1.5배(inset -25%).
function clamp(v: number, zz: number): number {
const el = win.current;
if (!el) return v;
const w = el.offsetWidth; // 창 크기(렌더 변환과 무관)
const m = Math.max(0, (w * 1.5 * zz - w) / 2);
return Math.max(-m, Math.min(m, v));
}
// 끌기 — setPointerCapture 로 창 밖으로 나가도 놓치지 않는다
function onDown(e: PointerEvt) {
drag.current = { id: e.pointerId, px: e.clientX, py: e.clientY };
e.currentTarget.setPointerCapture(e.pointerId);
}
function onMove(e: PointerEvt) {
const d = drag.current;
if (!d || e.pointerId !== d.id) return;
setX((prev) => clamp(prev + e.clientX - d.px, z));
setY((prev) => clamp(prev + e.clientY - d.py, z));
drag.current = { id: d.id, px: e.clientX, py: e.clientY };
}
function onUp(e: PointerEvt) {
if (drag.current && e.pointerId === drag.current.id) drag.current = null;
}
// 배율이 바뀌면 현재 x·y 를 새 범위로 다시 물린다(clamp)
function onZoom(e: RangeEvt) {
const nz = parseFloat(e.target.value);
setZ(nz);
setX((prev) => clamp(prev, nz));
setY((prev) => clamp(prev, nz));
}
// 키보드 — 화살표 키로 4px 씩 움직인다
function onKey(e: KeyEvt) {
const step = ARROWS[e.key];
if (!step) return;
e.preventDefault();
setX((prev) => clamp(prev + step[0], z));
setY((prev) => clamp(prev + step[1], z));
}
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--x": `${x}px`,
"--y": `${y}px`,
"--z": `${z}`,
};
return (
<section
className={loop ? `${styles.cu} ${styles.isDemo}` : styles.cu}
style={vars}
aria-label={label}
onPointerDown={() => setLoop(false)}
onFocus={() => setLoop(false)}
>
{/* 테두리와 창은 제자리 — 움직이는 것은 그 안의 사진뿐이다. 사진 파일은 쓰지 않는다 */}
<div className={styles.frame}>
<div
className={styles.window}
tabIndex={0}
aria-label={windowLabel}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={() => { drag.current = null; }}
onKeyDown={onKey}
>
<div className={styles.photo} aria-hidden="true" />
</div>
</div>
<div className={styles.controls}>
<CameraIcon />
<input
className={styles.zoom}
type="range"
min="1"
max="2"
step="0.01"
defaultValue="1"
aria-label={zoomLabel}
onChange={onZoom}
/>
{/* 결과 미리보기 — 같은 그라디언트를 4분의 1 이동으로 미러한다 */}
<span className={styles.preview} aria-hidden="true"><span className={styles.mini} /></span>
</div>
</section>
);
}09 · Photo positioning — Measured area 6.282% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Use it as the crop step right after a profile photo upload.
// The core — the allowed drag range is maxOffset = (photoSize * zoom - windowSize) / 2.
// Lower the zoom and the range narrows first, so a gap never opens at the window edge.
// The photo sheet is 1.5x the window (inset -25%)
function clamp(v, zz) {
var w = win.offsetWidth; // window size (unaffected by the render transform)
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);
}
// The fake photo carries one bright face blob and one dark shoulder blob —
// a lone gradient moved 10px and still failed the motion threshold (measured 13.8)
%cu-photo-bg {
background:
radial-gradient(circle at 32% 26%, $subject-cream 0 17%, rgba($subject-cream, 0) 38%),
radial-gradient(circle at 72% 76%, $stage-ink 0 15%, rgba($stage-ink, 0) 36%),
linear-gradient(160deg, $subject-sky 0%, $subject-lilac 34%, $av-violet 62%, $av-plum 100%);
}
// The transform gathers in one place — drag, zoom, and the preview share these variables
.cu__photo {
position: absolute;
inset: -25%;
transform: translate(var(--x, 0px), var(--y, 0px)) scale(var(--z, 1));
}
.cu__window { clip-path: circle(50%); }
Where it breaks — the trap
The first break the overflow gate caught was the mention list in 07. The absolutely positioned list carried top: 100%, but the height that percentage measured belonged to the padded root that reserves the space, not to the input row, so the list opened below the reserved area and the stage read 300px of viewport against 332px of content. Layout excludes an absolutely positioned box from its parent's height, so a scrollbar on the grid was the only symptom. Moving the anchor to the row — position: relative on the row itself — and leaving the root's padding-bottom equal to the open height put the list inside the reserved space, and the overflow went away.
The second break was the fake photo in 09. A picture painted as one smooth gradient moved by about 10px, and the motion measurement returned an intensity of 13.8, below the threshold of 15: neighboring pixels inside a gradient differ so little that a move the eye follows leaves almost nothing behind in the pixel diff. Laying one bright face blob and one dark shoulder blob over the gradient gave the picture real edges to carry, and the intensity rose to 16.0. The same arithmetic is why caption contrast over a gradient background has to be computed against the colors actually underneath — skip that step, and dark text over a dark stop quietly disappears.
The third was the mask prefix. The mask-image property 06 leans on measured 92.86% global support plus 4.17% partial at caniuse on 2026-09-12, with the unprefixed standard shipping since Chrome 120, Safari 15.4, and Firefox 53; while older versions linger, the -webkit-mask-image line stays next to it.
Finally, the state swaps in 02 and 06 were first built as fades, and the halfway frame showed both states translucent at once — a ghost frame printed straight into the poster. Switching to steps(1, end) cuts killed the ghosts, and because cuts alone starve the frame count, a shimmer bar and a dot pulse stayed on to keep the frames up.
The render numbers retell the same story from the other side. 07 moves its whole list for a cumulative changed area of 21.97%, the widest of the nine; 08 lands the hardest change at an intensity of 78.8; 09 stays the quietest at 16.0 even after the blobs, which is how calmly this part sits on a gradient. At the small end, 06 is one dot pulsing over 0.56% of the frame, yet it moves in 17 of the 24 captured frames — what keeps a small part from reading as a dead picture is frame count, not area. 03 staggers its 25 cells into 100 registered animations with movement across 23 of 24 frames, 01 fills the same 23, and 02 and 04 lean on the shimmer and the pop for their 11 and 14. Where values line up instead of people, data table UI patterns carry the column resizing and the cell copy. The finished nine, every break above fixed, are packed in the zip at the end of this page, and the password that opens it is dchsanq2.
Accessibility
Contrast here is not a matter of taste: every pair below was run through the WCAG 2.1 relative luminance formula, and only the passing pairs shipped. The initial letter is always the same cream, on all six tones.
| Foreground | Background | Measured contrast | Body threshold |
|---|---|---|---|
| cream #fff7e6 | blue #2a5fd8 | 5.29:1 | pass |
| cream #fff7e6 | violet #5b4bd6 | 5.76:1 | pass |
| cream #fff7e6 | teal #0d6e5c | 5.79:1 | pass |
| cream #fff7e6 | plum #a1266f | 6.52:1 | pass |
| cream #fff7e6 | brick #c2361a | 5.13:1 | pass |
| cream #fff7e6 | slate #3b4a63 | 8.40:1 | pass |
| cream #fff7e6 | ink #17141a | 17.11:1 | pass |
| ink #17141a | yellow #ffd23f | 12.63:1 | pass |
| ink #17141a | orange #ff4d1f | 5.50:1 | pass |
| ink #17141a | paper #ffffff | 18.24:1 | pass |
| cream #fff7e6 | orange #ff4d1f | 3.11:1 | fail — never used |
| cream #fff7e6 | yellow #ffd23f | 1.35:1 | fail — never used |
Under prefers-reduced-motion: reduce, all nine switch their animations off and keep every name, state, and value on screen. 01 stands with all six circles up, 02 rests on the initials step, and 03 draws the full pattern with the breathing stopped. 04 holds the last chosen shape across the four sizes, 05 stays stacked, and 06 keeps the status color and its text alive. 07 leaves the list open with the sweep bar parked on the first row, 08 rests with the card open and its activity bar full, and 09 parks the photo centered so the window never goes empty.
On the structural side, the initials come from job nouns only — Admin, Editor, Designer — never real names, and the initial letter is computed in JavaScript from the displayed string, so a language pack change carries it along. 01, 03, and 09 put a live input under the hand, a name field or a draggable window; every avatar in 05 is a button, so Tab walks the people one by one; 06 swaps a finished name-and-status sentence into aria-label on each change. 07 reads the highlighted row out loud through role="combobox", aria-expanded, and aria-activedescendant, and 08 ties the card to its trigger with aria-describedby while the open delay and the close grace belong to the pointer alone. The authority for the mask prefix and the pointer handoff is the mask-image documentation on MDN and the pointerenter reference; the contrast bar comes from the W3C's minimum contrast explanation. For more parts like these, the dashboard category collects them, and the about page explains how this site renders and measures every demo.
FAQ
What size should an avatar component ui be?
A set beats a single number. Fix four steps — 28, 36, 48, and 64px as in 04 — and bind the letter size and the ring width to calc(var(--size) * .4) and calc(var(--size) / 14); every size you pick keeps its ratio. On a narrow screen, prefer showing fewer rows over shrinking the circle. Trimming a position by hand, as the photo step in 09 does, extends all the way to ink in signature pad canvas drawing.
How do you show users who never uploaded a photo?
When the name exists, the auto-colored initial from 01 is the default. On a screen that fetches photo URLs, predefine the three steps as in 02 — waiting shimmer, initials, person mark — and hook the next step to the error event on the img, so a broken address never sits on the page as an empty pill.
Can the presence dot simply overlap the avatar?
Overlaid, it covers the face or slips past the rim. Punch a hole in the avatar face with a mask as in 06 and seat the dot inside it, and the dot lands in one place every time; swap the adjacent text and the aria-label with each state so the color never carries the information alone.








