08Avatar assignee picker
Each row carries an initials circle and a presence dot, and picking one fills the empty avatar slot on the button.
Best forFor the 'assign to' slot on an issue or task card, where the options are people
aria-selectedtranslateYbox-shadow
<main class="stage">
<div class="av is-demo is-open">
<span class="av__label" id="av-label">Assignee</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">Unassigned</span>
<span class="av__slot av__slot--b" aria-hidden="true">Peter Park</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">Hana Kim</span>
<span class="av__dot av__dot--on" title="Online"></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">Peter Park</span>
<span class="av__dot av__dot--away" title="Away"></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">Leo Lee</span>
<span class="av__dot" title="Offline"></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 Avatar assignee picker — Measured area 4.726% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · 9 Select Dropdown Design Patterns That Really Open
The post this demo lives in
9 Select Dropdown Design Patterns That Really OpenA hand-built select dropdown design replaces the gray box the browser draws for <select> with a button and a list of you See all demos in this post


