08아바타 담당자 지정
이름 옆 이니셜 동그라미와 상태 점이 같이 나오고, 고르면 버튼의 빈 아바타 자리가 그 사람으로 채워집니다.
쓰임이슈·업무 카드에서 '누구에게' 를 고르는 자리처럼 사람을 골라야 하는 선택을 붙일 때
aria-selectedtranslateYbox-shadow
<main class="stage">
<div class="av is-demo is-open">
<span class="av__label" id="av-label">담당자</span>
<button class="av__btn" type="button" id="av-btn" aria-haspopup="listbox" aria-expanded="true" aria-controls="av-list" aria-labelledby="av-label av-name">
<span class="av__slot-wrap">
<span class="av__empty av__slot av__slot--a"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 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-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg></span>
<span class="av__pic av__pic--b av__slot av__slot--b" aria-hidden="true">PS</span>
</span>
<span class="av__name" id="av-name">
<span class="av__slot av__slot--a">지정 안 됨</span>
<span class="av__slot av__slot--b" aria-hidden="true">박서준</span>
</span>
<span class="av__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg></span>
</button>
<ul class="av__list" id="av-list" role="listbox" aria-labelledby="av-label" tabindex="-1">
<li class="av__opt" id="av-o1" role="option" aria-selected="false">
<span class="av__pic av__pic--a">KH</span>
<span class="av__who">김하늘</span>
<span class="av__dot av__dot--on" title="온라인"></span>
</li>
<li class="av__opt av__opt--b" id="av-o2" role="option" aria-selected="false">
<span class="av__pic av__pic--b">PS</span>
<span class="av__who">박서준</span>
<span class="av__dot av__dot--away" title="자리 비움"></span>
</li>
<li class="av__opt" id="av-o3" role="option" aria-selected="false">
<span class="av__pic av__pic--c">LD</span>
<span class="av__who">이도윤</span>
<span class="av__dot" title="오프라인"></span>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
.av {
position: relative;
box-sizing: border-box;
width: min(212px, 84vw);
// 열린 목록은 absolute 라 높이에 안 잡힌다 — 그만큼 아래를 비워 둔다
padding-bottom: 104px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.av__label {
display: block;
font-size: 10px; line-height: 12px; font-weight: 800;
letter-spacing: .04em;
color: rgba(23, 20, 26, .72);
margin: 0 0 $sp-1;
}
.av__btn {
box-sizing: border-box;
width: 100%; height: 32px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border: 1px solid rgba(23, 20, 26, .16);
background: #fff;
border-radius: $r-control;
color: $stage-ink;
font-family: inherit;
font-size: 12px; font-weight: 700;
cursor: pointer;
}
.av__slot-wrap, .av__name { display: inline-grid; }
.av__slot { grid-area: 1 / 1; place-self: center start; }
.av__slot--b { opacity: 0; }
.av__empty {
display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 22px;
border-radius: 50%;
background: rgba(23, 20, 26, .07);
color: rgba(23, 20, 26, .4);
}
.av__pic {
display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 22px;
border-radius: 50%;
color: #fff;
font-size: 9px; font-weight: 800;
letter-spacing: .02em;
}
.av__pic--a { background: #2f8f6d; color: $subject-ink; } // 흰 이니셜 3.98:1 — 잉크 4.58:1
.av__pic--b { background: #9c9fce; }
.av__pic--c { background: #ba9273; color: $subject-ink; } // 흰 이니셜 3.60:1 — 잉크 5.07:1
.av__caret { margin-left: auto; display: inline-flex; color: rgba(23, 20, 26, .4); }
.av__list {
position: absolute;
top: 52px; left: 0; right: 0;
box-sizing: border-box;
margin: 0; padding: $sp-1;
list-style: none;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
opacity: 0;
transform: translateY(-4px);
pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing;
}
.av.is-open .av__list { opacity: 1; transform: translateY(0); pointer-events: auto; }
.av__opt {
position: relative;
isolation: isolate;
box-sizing: border-box;
height: 30px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
font-size: 12px; font-weight: 600;
cursor: pointer;
}
.av__opt::after {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: $r-sm;
background: rgba(47, 109, 246, .12);
opacity: 0;
}
.av__opt[aria-selected="true"]::after, .av__opt.is-at::after, .av__opt:hover::after { opacity: 1; }
.av__who { color: rgba(23, 20, 26, .82); }
.av__dot {
margin-left: auto;
width: 8px; height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, .22);
}
.av__dot--on { background: #50a26e; }
.av__dot--away { background: #bea57a; }
// ── 자동 루프: 두 번째 사람이 눌리면 빈 아바타 자리가 그 사람으로 채워졌다가 다시 비워진다 ──
// 트리거의 빈 자리와 채워진 아바타는 겹쳐 놓은 두 겹이라 steps(1) 로 한 번에 갈아 끼운다
.av.is-demo .av__slot--a { animation: av-swap-a $dur-loop steps(1, end) infinite; }
.av.is-demo .av__slot--b { animation: av-swap-b $dur-loop steps(1, end) infinite; }
.av.is-demo .av__opt--b::after { animation: av-in $dur-loop $easing infinite; }
.av.is-demo .av__opt--b .av__pic { animation: av-pop $dur-loop $easing infinite; }
@keyframes av-swap-a {
0%, 25% { opacity: 1; }
26%, 73% { opacity: 0; }
74%, 100% { opacity: 1; }
}
@keyframes av-swap-b {
0%, 25% { opacity: 0; }
26%, 73% { opacity: 1; }
74%, 100% { opacity: 0; }
}
@keyframes av-in {
0%, 100% { opacity: 0; transform: scale(.4); }
26%, 74% { opacity: 1; transform: scale(1); }
}
@keyframes av-pop {
0%, 100% { transform: scale(1); }
20% { transform: scale(1.18); }
40%, 74% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.av.is-demo .av__slot--a, .av.is-demo .av__slot--b,
.av.is-demo .av__opt--b::after, .av.is-demo .av__opt--b .av__pic { animation: none !important; }
.av__list { transition: none; }
}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);
}
.av {
position: relative;
box-sizing: border-box;
width: min(212px, 84vw);
padding-bottom: 104px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.av__label {
display: block;
font-size: 10px;
line-height: 12px;
font-weight: 800;
letter-spacing: 0.04em;
color: rgba(23, 20, 26, 0.72);
margin: 0 0 4px;
}
.av__btn {
box-sizing: border-box;
width: 100%;
height: 32px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border: 1px solid rgba(23, 20, 26, 0.16);
background: #fff;
border-radius: 12px;
color: #17141a;
font-family: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.av__slot-wrap, .av__name {
display: inline-grid;
}
.av__slot {
grid-area: 1/1;
place-self: center start;
}
.av__slot--b {
opacity: 0;
}
.av__empty {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.07);
color: rgba(23, 20, 26, 0.4);
}
.av__pic {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
color: #fff;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.02em;
}
.av__pic--a {
background: #2f8f6d;
color: #17141a;
}
.av__pic--b {
background: #9c9fce;
}
.av__pic--c {
background: #ba9273;
color: #17141a;
}
.av__caret {
margin-left: auto;
display: inline-flex;
color: rgba(23, 20, 26, 0.4);
}
.av__list {
position: absolute;
top: 52px;
left: 0;
right: 0;
box-sizing: border-box;
margin: 0;
padding: 4px;
list-style: none;
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: translateY(-4px);
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.av.is-open .av__list {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.av__opt {
position: relative;
isolation: isolate;
box-sizing: border-box;
height: 30px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.av__opt::after {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: 8px;
background: rgba(47, 109, 246, 0.12);
opacity: 0;
}
.av__opt[aria-selected=true]::after, .av__opt.is-at::after, .av__opt:hover::after {
opacity: 1;
}
.av__who {
color: rgba(23, 20, 26, 0.82);
}
.av__dot {
margin-left: auto;
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.22);
}
.av__dot--on {
background: #50a26e;
}
.av__dot--away {
background: #bea57a;
}
.av.is-demo .av__slot--a {
animation: av-swap-a 2s steps(1, end) infinite;
}
.av.is-demo .av__slot--b {
animation: av-swap-b 2s steps(1, end) infinite;
}
.av.is-demo .av__opt--b::after {
animation: av-in 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.av.is-demo .av__opt--b .av__pic {
animation: av-pop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes av-swap-a {
0%, 25% {
opacity: 1;
}
26%, 73% {
opacity: 0;
}
74%, 100% {
opacity: 1;
}
}
@keyframes av-swap-b {
0%, 25% {
opacity: 0;
}
26%, 73% {
opacity: 1;
}
74%, 100% {
opacity: 0;
}
}
@keyframes av-in {
0%, 100% {
opacity: 0;
transform: scale(0.4);
}
26%, 74% {
opacity: 1;
transform: scale(1);
}
}
@keyframes av-pop {
0%, 100% {
transform: scale(1);
}
20% {
transform: scale(1.18);
}
40%, 74% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.av.is-demo .av__slot--a, .av.is-demo .av__slot--b,
.av.is-demo .av__opt--b::after, .av.is-demo .av__opt--b .av__pic {
animation: none !important;
}
.av__list {
transition: none;
}
}// 실제 동작: 사람을 고르면 버튼의 빈 아바타 자리가 그 사람의 이니셜 원으로 채워진다.
var root = document.querySelector('.av');
var btn = document.getElementById('av-btn');
var list = document.getElementById('av-list');
var wrap = root.querySelector('.av__slot-wrap');
var name = document.getElementById('av-name');
var opts = [].slice.call(list.querySelectorAll('.av__opt'));
var at = 0;
function open(on) {
root.classList.remove('is-demo');
root.classList.toggle('is-open', on);
btn.setAttribute('aria-expanded', String(on));
if (on) list.focus({ preventScroll: true });
}
function pick(i) {
root.classList.remove('is-demo');
at = i;
opts.forEach(function (o, k) { o.setAttribute('aria-selected', String(k === i)); });
var pic = opts[i].querySelector('.av__pic');
wrap.innerHTML = '<span class="' + pic.className + '">' + pic.textContent + '</span>';
name.textContent = opts[i].querySelector('.av__who').textContent;
open(false);
btn.focus({ preventScroll: true });
}
btn.addEventListener('click', function () { open(!root.classList.contains('is-open')); });
opts.forEach(function (o, i) { o.addEventListener('click', function () { pick(i); }); });
list.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
at = Math.max(0, Math.min(at + (e.key === 'ArrowDown' ? 1 : -1), opts.length - 1));
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
list.setAttribute('aria-activedescendant', opts[at].id);
e.preventDefault();
} else if (e.key === 'Enter' || e.key === ' ') { pick(at); e.preventDefault(); }
else if (e.key === 'Escape') { open(false); btn.focus({ preventScroll: true }); }
});
document.addEventListener('click', function (e) { if (!e.target.closest('.av')) open(false); });import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AssigneeSelect.module.scss";
export type Assignee = {
initials: string;
name: string;
status: "online" | "away" | "offline";
};
export type AssigneeSelectProps = {
label?: string;
emptyText?: string;
people?: Assignee[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
type ClickEvt = { stopPropagation: () => void };
const DEFAULT_PEOPLE: Assignee[] = [
{ initials: "HK", name: "Hana Kim", status: "online" },
{ initials: "PP", name: "Peter Park", status: "away" },
{ initials: "LL", name: "Leo Lee", status: "offline" },
];
const TONES = [styles.picA, styles.picB, styles.picC];
const STATUS_TEXT: Record<Assignee["status"], string> = {
online: "Online",
away: "Away",
offline: "Offline",
};
const STATUS_CLASS: Record<Assignee["status"], string> = {
online: styles.dotOn,
away: styles.dotAway,
offline: "",
};
export default function AssigneeSelect({
label = "Assignee",
emptyText = "Unassigned",
people = DEFAULT_PEOPLE,
duration = 180,
easing = "cubic-bezier(.34,1.56,.64,1)",
color = "#2f6df6",
}: AssigneeSelectProps) {
const [open, setOpen] = useState(false);
const [picked, setPicked] = useState<number | null>(null);
const [active, setActive] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const current = picked === null ? null : people[picked];
useEffect(() => {
function onDocClick(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("click", onDocClick);
return () => document.removeEventListener("click", onDocClick);
}, []);
useEffect(() => {
if (open) listRef.current?.focus({ preventScroll: true });
}, [open]);
function pick(i: number) {
setPicked(i);
setActive(i);
setOpen(false);
btnRef.current?.focus({ preventScroll: true });
}
return (
<div className={`${styles.av} ${open ? styles.isOpen : ""}`} style={vars} ref={rootRef}>
<span className={styles.label} id="av-label">{label}</span>
<button
ref={btnRef}
type="button"
id="av-btn"
className={styles.btn}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls="av-list"
aria-labelledby="av-label av-name"
onClick={() => setOpen((o) => !o)}
>
{current === null ? (
<span className={styles.empty}>
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2" />
<path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 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-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56" />
</g>
</svg>
</span>
) : (
<span className={`${styles.pic} ${TONES[(picked ?? 0) % TONES.length]}`}>{current.initials}</span>
)}
<span className={styles.name} id="av-name">{current === null ? emptyText : current.name}</span>
<span className={styles.caret} aria-hidden="true">
<svg viewBox="0 0 256 256" width="12" height="12">
<g fill="currentColor">
<path d="m208 96l-80 80l-80-80Z" opacity=".2" />
<path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z" />
</g>
</svg>
</span>
</button>
<ul
ref={listRef}
id="av-list"
className={styles.list}
role="listbox"
aria-labelledby="av-label"
aria-activedescendant={`av-o${active}`}
tabIndex={-1}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown") { setActive((a) => Math.min(a + 1, people.length - 1)); e.preventDefault(); }
else if (e.key === "ArrowUp") { setActive((a) => Math.max(a - 1, 0)); e.preventDefault(); }
else if (e.key === "Enter" || e.key === " ") { pick(active); e.preventDefault(); }
else if (e.key === "Escape") { setOpen(false); btnRef.current?.focus({ preventScroll: true }); }
}}
>
{people.map((p, i) => (
<li
key={p.name}
id={`av-o${i}`}
className={`${styles.opt} ${i === active ? styles.isAt : ""} ${i === picked ? styles.isPicked : ""}`}
role="option"
aria-selected={i === picked}
onMouseMove={() => setActive(i)}
onClick={() => pick(i)}
>
<span className={`${styles.pic} ${TONES[i % TONES.length]}`}>{p.initials}</span>
<span className={styles.who}>{p.name}</span>
<span className={`${styles.dot} ${STATUS_CLASS[p.status]}`} title={STATUS_TEXT[p.status]} />
</li>
))}
</ul>
</div>
);
}08 아바타 담당자 지정 — 측정 누적면적 4.726% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 셀렉트박스 디자인 9종 — 진짜 열리는 드롭다운
이 데모가 속한 글
css 셀렉트박스 디자인 9종 — 진짜 열리는 드롭다운css 셀렉트박스 디자인은 브라우저가 제 마음대로 그리는 기본 <select> 상자를 버리고, 버튼과 목록을 직접 만들어 여닫는 방식입니다. 아홉 개 중 하나는 목록을 아예 열지 않으며, 그 안의 품절 칸은 눌러도 이 글의 데모 전부 보기


