css 셀렉트박스 디자인 9종 — 진짜 열리는 드롭다운
css 셀렉트박스 디자인은 브라우저가 제 마음대로 그리는 기본 <select> 상자를 버리고, 버튼과 목록을 직접 만들어 여닫는 방식입니다. 아홉 개 중 하나는 목록을 아예 열지 않으며, 그 안의 품절 칸은 눌러도 꿈쩍하지 않습니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 스프링으로 열리는 기본 셀렉트
- 02 키보드 하이라이트 레일
- 03 검색해서 좁히는 콤보박스
- 04 그룹 헤더가 붙는 목록
- 05 다중 선택 칩 토큰
- 06 국기 붙은 국가·통화 선택
- 07 컬러 스와치 격자 선택
- 08 아바타 담당자 지정
- 09 펼치지 않는 크기 세그먼트
아홉 개는 유행하는 모양 순서가 아니라 셀렉트박스가 실제로 겪는 문제 순서대로 놓았습니다. 먼저 열리고(01), 열린 목록 안에서 키보드로 돌아다니고(02), 선택지가 많아지면 검색으로 좁히고(03), 그래도 많으면 묶음으로 나눕니다(04). 그다음은 한 개가 아니라 여러 개를 고르는 경우(05)이고, 마지막 넷은 목록이 글자 줄만으로 끝나지 않을 때입니다. 나라(06)와 색(07)과 사람(08)은 각각 국기·동그라미·이니셜을 먼저 보고 고르며, 선택지가 넷뿐인 사이즈(09)는 목록을 여는 동작 자체를 없앱니다. 아홉 개 전부 role="listbox"와 role="option"을 달고 화살표 키가 실제로 듣고, 목록을 여닫는 일곱 개는 Escape에도 반응합니다.
01스프링으로 열리는 기본 셀렉트
버튼을 누르면 목록이 위 모서리를 기준으로 튕기듯 커지며 실제로 열리고, 항목을 고르면 버튼 글자가 그 값으로 바뀝니다. 열고 닫는 느낌은 transform-origin을 목록 위쪽에 두고 scaleY를 0.72에서 1로 밀어 만듭니다.
.ss__list {
transform-origin: top center;
transform: translateY(-6px) scaleY(.72);
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.ss.is-open .ss__list { transform: translateY(0) scaleY(1); opacity: 1; pointer-events: auto; }
02키보드 하이라이트 레일
위아래 화살표를 누르면 색칠된 막대 하나가 항목 사이를 미끄러져 옮겨 다니고, Home과 End로 양 끝까지 한 번에 갑니다. 줄마다 배경색을 켜고 끄는 대신 막대 한 개를 translateY로 옮기기 때문에 칠해지는 면적이 아니라 위치만 바뀝니다.
function move(i) {
root.classList.remove('is-demo');
at = Math.max(0, Math.min(i, opts.length - 1));
rail.style.transform = 'translateY(' + (at * ROW) + 'px)';
list.setAttribute('aria-activedescendant', opts[at].id);
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
}
list.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { move(at + 1); e.preventDefault(); }
else if (e.key === 'ArrowUp') { move(at - 1); e.preventDefault(); }
else if (e.key === 'Home') { move(0); e.preventDefault(); }
else if (e.key === 'End') { move(opts.length - 1); e.preventDefault(); }
});
03검색해서 좁히는 콤보박스
글자를 치면 목록이 실제로 줄어들고, 맞아떨어진 글자에만 형광 배경이 깔립니다. 남는 항목이 없으면 빈 상태 문구가 대신 나오고, Escape를 누르면 입력이 비워지면서 목록이 원래대로 돌아옵니다.
function render(q) {
root.classList.remove('is-demo');
var shown = 0;
opts.forEach(function (o, i) {
var hit = !q || names[i].indexOf(q) >= 0;
o.hidden = !hit;
if (hit) {
shown++;
var k = names[i].indexOf(q);
o.innerHTML = !q ? names[i]
: names[i].slice(0, k) + '<span class="cb__m is-on">' + q + '</span>' + names[i].slice(k + q.length);
}
});
empty.hidden = shown > 0;
at = -1;
}
04그룹 헤더가 붙는 목록
스크롤을 내리면 지금 보고 있는 묶음의 제목이 목록 맨 위에 붙어 남고, 다음 묶음이 올라오면 그대로 밀려 올라갑니다. 붙이는 일은 position: sticky 한 줄이 하고, 목록 상자에는 68px짜리 실제 스크롤 영역을 두었습니다.
.gs__scroll {
height: 68px;
overflow-y: auto;
scrollbar-width: thin;
overscroll-behavior: contain;
}
.gs__head {
position: sticky;
top: 0;
z-index: 1;
box-sizing: border-box;
height: 20px;
margin: 0; padding: 0 $sp-2;
display: flex; align-items: center;
background: #fff;
font-size: 9px; font-weight: 800;
letter-spacing: .08em;
color: rgba(23, 20, 26, .38);
}
05다중 선택 칩 토큰
고른 항목이 입력칸 안에 알약 모양 칩으로 쌓이고, 칩의 x를 누르거나 입력칸에서 Backspace를 치면 마지막 칩부터 실제로 빠집니다. 목록 요소 자체에 aria-multiselectable="true"가 붙어 있어 하나를 고를 때 나머지가 풀리지 않습니다.
field.addEventListener('keydown', function (e) {
if (e.key === 'Backspace') {
var last = chips.querySelector('.mc__chip:last-child');
if (last && last.dataset.for) sync(document.getElementById(last.dataset.for));
e.preventDefault();
} else 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); });
field.setAttribute('aria-activedescendant', opts[at].id);
e.preventDefault();
} else if (e.key === 'Enter' || e.key === ' ') { sync(opts[at]); e.preventDefault(); }
else if (e.key === 'Escape') { root.classList.remove('is-open'); field.blur(); }
});
06국기 붙은 국가·통화 선택
국가를 고르면 버튼의 국기와 통화 기호가 한꺼번에 바뀝니다. 국기는 이미지 파일도 이모지도 아니고 viewBox="0 0 16 12" 안에 사각형 두세 개나 사각형 위의 원 하나로 그린 인라인 SVG여서, 윈도우 크롬처럼 국기 이모지를 글자로 보여 주는 환경에서도 똑같이 그려집니다.
function pick(i) {
at = i;
root.classList.remove('is-demo');
opts.forEach(function (o, k) { o.setAttribute('aria-selected', String(k === i)); });
swap.innerHTML = opts[i].querySelector('.cf__flag').innerHTML;
name.textContent = opts[i].querySelector('.cf__opt-name').textContent;
cur.textContent = opts[i].dataset.cur;
open(false);
btn.focus({ preventScroll: true });
}
07컬러 스와치 격자 선택
줄줄이 늘어선 목록 대신 4열짜리 색 동그라미 격자가 열리고, 체크 표시 하나가 고른 색 위로 건너갑니다. 체크를 색마다 하나씩 두는 대신 한 개만 만들어 translate로 옮기기 때문에 격자 칸이 늘어도 움직이는 요소는 그대로 하나입니다.
function place(i) {
ring.style.transform = 'translate(' + ((i % 4) * STEP) + 'px,' + (Math.floor(i / 4) * STEP) + 'px)';
}
function pick(i) {
root.classList.remove('is-demo');
at = i;
sws.forEach(function (s, k) { s.setAttribute('aria-selected', String(k === i)); });
place(i);
preview.innerHTML = '<span class="cs__dot" style="background:' + sws[i].style.background + '"></span>';
name.textContent = sws[i].getAttribute('aria-label');
}
08아바타 담당자 지정
이름 옆에 이니셜 동그라미와 접속 상태 점이 함께 나오고, 한 사람을 고르면 버튼의 빈 아바타 자리가 그 사람으로 채워집니다. 빈 자리는 직접 그린 사람 모양이 아니라 Phosphor Duotone의 user 아이콘 SVG를 그대로 넣은 것입니다.
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 });
}
09펼치지 않는 크기 세그먼트
선택지가 넷뿐이라 아무것도 열지 않고 알약 하나가 칸 사이를 미끄러집니다. 품절 칸에는 aria-disabled="true"가 붙어 있어 눌러도 알약이 그쪽으로 가지 않고, 화살표 키로 지나가려 해도 같은 검사가 막습니다.
function pick(i) {
if (i < 0 || i >= cells.length) return;
if (cells[i].getAttribute('aria-disabled') === 'true') return;
root.classList.remove('is-demo');
at = i;
pill.style.transform = 'translateX(' + (i * W) + 'px)';
cells.forEach(function (c, k) { c.setAttribute('aria-selected', String(k === i)); });
track.setAttribute('aria-activedescendant', cells[i].id);
}
어디서 깨지는가 — 함정
열린 목록을 position: absolute로 띄우는 순간, 그 목록은 부모의 높이 계산에서 통째로 빠집니다. 화면 가운데 정렬은 버튼과 라벨만 놓고 계산되고 목록은 그 아래로 삐져나가는데, 넓은 화면에서는 여백이 넉넉해 티가 나지 않습니다. 320px 폭으로 줄여 재어 보니 아홉 개 중 여섯 개에서 문서 높이가 화면보다 길어졌고, 01번은 화면이 200px인데 내용이 230px이었습니다. 30px이면 목록 마지막 줄이 잘려 나가는 길이입니다. 고친 방법은 목록을 다시 흐름 안으로 넣는 게 아니라, 부품을 감싼 바깥 상자에 목록 높이만큼 padding-bottom을 줘서 비어 있는 자리를 미리 잡아 두는 것이었습니다. 01번은 116px, 04번은 80px처럼 항목마다 값이 다릅니다.
둘째 함정은 04번에서 나왔습니다. position: sticky는 진짜 스크롤이 있어야 발동합니다. 자동 재생 화면에서 목록을 transform으로 밀어 올리면 스크롤 위치는 0 그대로라서 머리글도 함께 밀려 올라가고, 붙는 장면이 아예 찍히지 않습니다. 그래서 자동 재생에서는 머리글에 반대 방향 이동을 따로 걸어 붙어 있는 것처럼 보이게 하고, 사람이 마우스 휠이나 키보드로 실제로 스크롤할 때는 손대지 않은 position: sticky가 그대로 일합니다. 이 두 가지를 다 고친 소스는 압축 비밀번호 c7dd2vxx를 넣으면 열리는 zip 안에 아홉 벌 그대로 들어 있습니다.
접근성
아홉 개 전부 prefers-reduced-motion: reduce에서 자동 재생 루프를 끄되 선택 상태와 위치 값은 그대로 남깁니다. 막대가 미끄러지지 않고 바로 자리에 서고, 목록은 커지는 과정 없이 열린 상태로 나타나며, 무엇이 선택돼 있는지는 aria-selected가 계속 알려 줍니다. 목록을 띄우는 여섯 개의 트리거 버튼에는 aria-haspopup="listbox"와 aria-expanded가 붙어 지금 열려 있는지를 읽어 주고, 열린 목록은 aria-activedescendant로 초점을 옮기지 않은 채 지금 가리키는 줄만 바꿉니다. 여기서 쓴 역할 이름과 속성의 정의는 MDN의 listbox 역할 문서에 정리돼 있습니다. 초점을 되돌릴 때 focus({ preventScroll: true })를 쓰는 이유는 이 데모들이 글 안에 iframe으로 박혀 있어, 그냥 focus()를 부르면 읽던 사람의 화면이 아래로 끌려가기 때문입니다.
같은 자리에서 쓰는 입력 부품은 폼·입력 카테고리에, 눌렀을 때 반응하는 부품은 클릭 카테고리에 모아 두었습니다.
FAQ
기본 <select>를 놔두고 굳이 새로 만들 이유가 있나요?
모양을 바꾸려는 것뿐이라면 없습니다. 기본 <select>는 키보드와 스크린 리더가 이미 완벽하게 알아듣고, 모바일에서는 운영체제가 제 방식대로 예쁘게 띄워 줍니다. 직접 만들 이유는 목록 안에 국기나 색 동그라미나 아바타처럼 글자가 아닌 것을 넣어야 할 때, 또는 여러 개를 칩으로 고르게 해야 할 때입니다. 그 경우에도 역할과 키보드 동작은 기본 <select>가 하던 만큼 직접 채워 넣어야 합니다.
아홉 개 전부 키보드만으로 쓸 수 있나요?
네. 트리거는 Tab으로 닿고 Enter나 Space로 열리며, 열린 목록에서는 위아래 화살표로 옮겨 다니고 Enter로 고르고 Escape로 닫으면서 초점이 트리거로 돌아옵니다. 여는 동작이 없는 02번과 09번은 목록 자체가 tabindex="0"으로 Tab 순서에 들어갑니다. 02번은 Home과 End로 양 끝까지 가고, 07번은 좌우 화살표로 옆 칸, 위아래 화살표로 윗줄과 아랫줄로 건너뜁니다.
React 버전에서도 애니메이션 값을 바꿀 수 있나요?
바꿀 수 있습니다. 아홉 개 컴포넌트가 모두 duration·easing·color 세 개를 props로 받아 --duration·--easing·--color 사용자 지정 속성으로 내려보내고, 스타일 파일은 그 변수만 읽습니다. 값 하나만 바꾸면 아홉 개가 같은 리듬으로 함께 움직입니다.