05겹친 아바타와 +N
네 사람만 겹쳐 보이고 나머지는 +N 칸 하나로 접히며, 손이 닿거나 초점이 들어오면 겹침이 풀려 한 사람씩 옆으로 벌어집니다.
쓰임문서·과제에 참여한 사람을 한 줄로 보여 주는 곳
translateXanimation-delay:focus-visible
<main class="stage">
<section class="st is-demo" aria-label="문서 참여자">
<div class="st__stack">
<button class="st__av st__av--1" type="button" style="--i: 0" data-name="관리자" aria-label="관리자"></button>
<button class="st__av st__av--2" type="button" style="--i: 1" data-name="편집자" aria-label="편집자"></button>
<button class="st__av st__av--3" type="button" style="--i: 2" data-name="디자이너" aria-label="디자이너"></button>
<button class="st__av st__av--4" type="button" style="--i: 3" data-name="개발자" aria-label="개발자"></button>
<button class="st__more" type="button" style="--i: 4" aria-expanded="false" aria-label="접힌 참여자 펼치기">+3</button>
</div>
<p class="st__line">문서에 일곱 명이 참여 중</p>
<ul class="st__folded">
<li class="st__row" hidden><span class="st__mini st__mini--5" data-name="검토자" aria-hidden="true"></span>검토자</li>
<li class="st__row" hidden><span class="st__mini st__mini--6" data-name="번역가" aria-hidden="true"></span>번역가</li>
<li class="st__row" hidden><span class="st__mini st__mini--7" data-name="운영자" aria-hidden="true"></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-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 겹친 아바타와 +N — 측정 누적면적 4.666% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 아바타 UI 9종 — 사진 파일 없이 만드는 사용자 표시


