CSS만으로 커스텀 셀렉트 박스 — JavaScript 150줄이 사라지는 순간
<select> 요소를 커스터마이징하려면 지금까지 두 가지 선택지가 있었다. 하나는 네이티브 <select>를 쓰되 스타일링을 포기하는 것. 다른 하나는 <div>와 JavaScript로 셀렉트 박스를 처음부터 다시 만드는 것이다. 키보드 내비게이션, 포커스 관리, 스크린 리더 지원, 외부 클릭 닫기 — 이걸 직접 구현하면 JavaScript만 150줄이 넘는다. Chrome 135에 도입된 appearance: base-select는 이 딜레마를 해결한다. 네이티브 접근성을 그대로 유지하면서, 드롭다운의 모든 요소를 CSS로 자유롭게 스타일링할 수 있다.
appearance: base-select가 뭔가
appearance: base-select는 브라우저의 기본 <select> 렌더링을 커스터마이징 가능 모드로 전환하는 CSS 속성이다. 비유하면 스마트폰의 "개발자 모드"와 같다. 기본 기능(전화, 메시지)은 그대로 유지하면서, 숨겨진 설정과 커스터마이징 옵션이 열린다.
select,
select::picker(select) {
appearance: base-select;
}이 두 줄만 추가하면:
| 기능 | 기존 <select> | base-select 적용 후 |
|---|---|---|
| 드롭다운 스타일링 | 불가 (OS 기본 렌더링) | CSS로 완전 커스터마이징 |
| 키보드 내비게이션 | 지원 | 그대로 지원 |
| 스크린 리더 | 지원 | 그대로 지원 |
| 포커스 관리 | 지원 | 그대로 지원 |
| 앵커 포지셔닝 | 없음 | 자동 (overflow 처리 포함) |
JavaScript는 0줄이다. 키보드(화살표, Enter, Escape), 포커스, 스크린 리더 지원이 브라우저 네이티브로 동작한다.
[💡 잠깐! 이 용어는?] 앵커 포지셔닝(Anchor Positioning): 드롭다운 같은 팝오버 요소가 기준 요소(앵커)에 상대적으로 위치를 잡는 방식. 화면 하단에 공간이 부족하면 자동으로 위로 열리는 등의 폴백 처리를 포함한다.
::picker(select) 의사 요소
드롭다운 패널 자체를 스타일링하려면 ::picker(select) 의사 요소를 사용한다.
select::picker(select) {
margin-block-end: 1em;
border-radius: 12px;
border: 1px solid #e0e0e0;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
padding: 8px;
background: white;
}
option {
padding: 12px 16px;
border-radius: 8px;
transition: background-color 0.15s ease;
}
option:hover {
background-color: #f0f4ff;
}
option:checked {
background-color: #3b82f6;
color: white;
}기존에는 이 정도의 커스터마이징을 하려면 <div> 기반 셀렉트 컴포넌트를 만들고, aria-role, aria-expanded, aria-activedescendant 같은 ARIA 속성을 일일이 관리해야 했다. base-select는 이 모든 것을 브라우저가 처리한다.
sibling-index() — CSS에서 인덱스를 쓸 수 있다
Chrome 135에 함께 도입된 sibling-index() 함수는 요소의 형제 순서(1부터 시작)를 반환한다. 이전에는 :nth-child(1), :nth-child(2), :nth-child(3)을 각각 작성하거나, HTML에 style="--index: 1" 같은 인라인 변수를 넣어야 했다. sibling-index()는 이 수작업을 없앤다.
option {
opacity: 0;
translate: 30px 0;
transition:
opacity 0.25s ease,
translate 0.5s ease;
transition-delay: calc(0.05s * (sibling-index() - 1));
}
select::picker(select):popover-open option {
opacity: 1;
translate: 0 0;
}드롭다운이 열릴 때 각 옵션이 순서대로 슬라이드 인하는 애니메이션이 CSS만으로 완성된다. JavaScript로 forEach를 돌며 transitionDelay를 설정할 필요가 없다. 비유하면 출석부를 부르는 대신, 학생들이 자기 번호를 알아서 말하는 것과 같다.
[💡 잠깐! 이 용어는?] sibling-index(): CSS에서 요소가 부모 내 형제 요소들 중 몇 번째인지를 반환하는 함수. 1부터 시작한다. 항목이 추가되거나 삭제되면 자동으로 재계산된다.
typed attr() — HTML 속성을 CSS 값으로
attr() 함수는 원래 content 속성에서만 쓸 수 있었다. Chrome 135부터는 색상, 길이, 숫자 등 모든 CSS 값 타입으로 HTML 속성을 읽을 수 있다.
<select>
<option data-bg-color="#F8C9A0" value="charmander">파이리</option>
<option data-bg-color="#A0C8F8" value="squirtle">꼬부기</option>
<option data-bg-color="#A0F8A0" value="bulbasaur">이상해씨</option>
</select>option {
background-color: attr(data-bg-color color, transparent);
padding: 12px 16px;
border-radius: 8px;
}HTML의 data-bg-color 속성값이 CSS color 타입으로 파싱되어 background-color에 적용된다. 두 번째 인자 transparent는 속성이 없을 때의 폴백 값이다. CSS-in-JS 없이도 데이터 기반 스타일링이 가능해진다.
JavaScript 150줄 vs CSS 30줄 비교
| 항목 | JavaScript 커스텀 셀렉트 | CSS base-select |
|---|---|---|
| 드롭다운 열기/닫기 | JS 이벤트 핸들링 (~20줄) | 브라우저 네이티브 |
| 키보드 내비게이션 | JS 키 이벤트 처리 (~30줄) | 브라우저 네이티브 |
| 외부 클릭 닫기 | JS document 클릭 리스너 (~15줄) | 브라우저 네이티브 |
| 포커스 관리 | JS focus/blur 처리 (~20줄) | 브라우저 네이티브 |
| ARIA 속성 | JS 동적 속성 업데이트 (~25줄) | 브라우저 네이티브 |
| 애니메이션 | JS setTimeout/requestAnimationFrame (~20줄) | CSS transition + sibling-index() |
| 데이터 기반 색상 | JS DOM 조작 (~20줄) | CSS typed attr() |
| 합계 | ~150줄 JavaScript + CSS | ~30줄 CSS |
비유하면 가구를 직접 목공하는 것(JavaScript)과 IKEA 가구를 조립하는 것(base-select)의 차이다. 결과물의 품질은 비슷하지만, 투입하는 시간과 전문성이 전혀 다르다. 게다가 IKEA 가구 쪽이 접근성 인증서(브라우저 네이티브 접근성)도 함께 딸려 온다.
브라우저 지원 현황
| 기능 | Chrome | Firefox | Safari |
|---|---|---|---|
appearance: base-select | 135+ | 미지원 | 미지원 |
sibling-index() | 135+ | 미지원 | 미지원 |
sibling-count() | 135+ | 미지원 | 미지원 |
typed attr() | 135+ | 미지원 | 미지원 |
2026년 2월 기준 Chrome 전용이다. 프로그레시브 인핸스먼트 전략으로 접근하는 것이 현실적이다. base-select를 지원하지 않는 브라우저에서는 기본 <select>가 렌더링되므로 기능적으로는 문제가 없다. 스타일만 다를 뿐이다.
[💡 잠깐! 이 용어는?] 프로그레시브 인핸스먼트(Progressive Enhancement): 기본 기능은 모든 브라우저에서 동작하게 하고, 최신 브라우저에서는 추가 기능(스타일, 애니메이션 등)을 점진적으로 적용하는 전략이다.
정리
- Chrome 135에
appearance: base-select가 도입되어 네이티브<select>를 CSS로 완전히 커스터마이징할 수 있게 됐다 ::picker(select)의사 요소로 드롭다운 패널의 스타일을 자유롭게 지정한다sibling-index()로 형제 요소의 인덱스를 CSS에서 직접 사용할 수 있다- typed
attr()로 HTML 속성을 색상, 길이 등 CSS 값 타입으로 읽을 수 있다 - 키보드 내비게이션, 포커스 관리, ARIA 지원은 브라우저가 네이티브로 처리한다
- 현재 Chrome 135+ 전용이지만, 프로그레시브 인핸스먼트로 안전하게 적용 가능하다
프로젝트에 <div> 기반 커스텀 셀렉트 컴포넌트가 있다면, Chrome 지원이 확대된 시점에 base-select로 교체하는 것을 검토해볼 만하다. JavaScript 150줄이 CSS 30줄로 줄어들면서 접근성은 오히려 향상되는 드문 케이스다.
참고:
- CSS in 2026 — LogRocket Blog: https://blog.logrocket.com/css-in-2026/
- MDN — appearance: https://developer.mozilla.org/en-US/docs/Web/CSS/appearance
- Chrome 135 Release Notes: https://developer.chrome.com/blog/chrome-135-beta
관심 있을 만한 포스트
sibling-index()로 만드는 CSS 스크롤 소용돌이 — JavaScript 없이 수백 개 요소 애니메이션
CSS sibling-index()와 scroll-driven animations를 결합해 순수 CSS만으로 텍스트 보텍스 효과를 구현하는 기법을 다룬다.
CSS @property — 커스텀 속성에 타입을 부여하는 방법
CSS @property at-rule로 커스텀 속성에 타입 정의, 상속 제어, 폴백을 추가해 렌더링 안정성과 애니메이션 가능성을 확보하는 방법을 다룬다.
CSS :near() — 마우스가 '가까이' 오면 반응하는 새로운 의사 클래스
CSS Working Group에 제안된 :near() 의사 클래스는 포인터 근접성을 감지해 호버 전에 UI를 활성화하는 새로운 상호작용 패턴을 연다.
Google WebMCP — 웹사이트가 AI 에이전트에게 '메뉴판'을 건네는 시대
Chrome 146에 탑재된 WebMCP의 Declarative·Imperative API 구조와 웹 개발자가 준비해야 할 변화를 분석한다.
V8 Explicit Compile Hints — 주석 한 줄로 JavaScript 시작 속도를 630ms 줄이는 법
Chrome 136에 도입된 V8의 Explicit Compile Hints 기능으로 JavaScript 초기 로딩 성능을 개선하는 원리와 사용법을 분석한다.
V8의 JSON.stringify가 2배 빨라졌다 — 6가지 최적화 기법 해부
V8 13.8(Chrome 138)에서 적용된 JSON.stringify 성능 개선의 기술적 배경과 6가지 핵심 최적화 전략을 분석한다.
Temporal API — JavaScript Date의 30년 묵은 저주가 풀린다
Chrome 144가 Temporal API를 정식 탑재하면서 JavaScript 날짜 처리의 새 시대가 열렸다.
Interop 2026 — 브라우저 전쟁이 끝나고 표준 전쟁이 시작됐다
Chrome, Safari, Firefox가 합의한 20개 웹 표준 집중 영역과 프론트엔드 개발자가 주목해야 할 핵심 기능을 정리한다.