CSS만으로 커스텀 셀렉트 박스 — JavaScript 150줄이 사라지는 순간

10 min read
CSSbase-selectsibling-indexChrome커스텀 셀렉트
CSS만으로 커스텀 셀렉트 박스 — JavaScript 150줄이 사라지는 순간

<select> 요소를 커스터마이징하려면 지금까지 두 가지 선택지가 있었다. 하나는 네이티브 <select>를 쓰되 스타일링을 포기하는 것. 다른 하나는 <div>와 JavaScript로 셀렉트 박스를 처음부터 다시 만드는 것이다. 키보드 내비게이션, 포커스 관리, 스크린 리더 지원, 외부 클릭 닫기 — 이걸 직접 구현하면 JavaScript만 150줄이 넘는다. Chrome 135에 도입된 appearance: base-select는 이 딜레마를 해결한다. 네이티브 접근성을 그대로 유지하면서, 드롭다운의 모든 요소를 CSS로 자유롭게 스타일링할 수 있다.


appearance: base-select가 뭔가

appearance: base-select는 브라우저의 기본 <select> 렌더링을 커스터마이징 가능 모드로 전환하는 CSS 속성이다. 비유하면 스마트폰의 "개발자 모드"와 같다. 기본 기능(전화, 메시지)은 그대로 유지하면서, 숨겨진 설정과 커스터마이징 옵션이 열린다.

base-select 활성화
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()는 이 수작업을 없앤다.

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 속성을 읽을 수 있다.

data 속성으로 옵션별 색상 지정
<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>
typed attr()로 동적 배경색 적용
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 가구 쪽이 접근성 인증서(브라우저 네이티브 접근성)도 함께 딸려 온다.


브라우저 지원 현황

기능ChromeFirefoxSafari
appearance: base-select135+미지원미지원
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줄로 줄어들면서 접근성은 오히려 향상되는 드문 케이스다.


참고:

관심 있을 만한 포스트

sibling-index()로 만드는 CSS 스크롤 소용돌이 — JavaScript 없이 수백 개 요소 애니메이션

CSS sibling-index()와 scroll-driven animations를 결합해 순수 CSS만으로 텍스트 보텍스 효과를 구현하는 기법을 다룬다.

CSSsibling-index

CSS @property — 커스텀 속성에 타입을 부여하는 방법

CSS @property at-rule로 커스텀 속성에 타입 정의, 상속 제어, 폴백을 추가해 렌더링 안정성과 애니메이션 가능성을 확보하는 방법을 다룬다.

CSS@property

CSS :near() — 마우스가 '가까이' 오면 반응하는 새로운 의사 클래스

CSS Working Group에 제안된 :near() 의사 클래스는 포인터 근접성을 감지해 호버 전에 UI를 활성화하는 새로운 상호작용 패턴을 연다.

CSS:near()

Google WebMCP — 웹사이트가 AI 에이전트에게 '메뉴판'을 건네는 시대

Chrome 146에 탑재된 WebMCP의 Declarative·Imperative API 구조와 웹 개발자가 준비해야 할 변화를 분석한다.

WebMCPAI Agent

V8 Explicit Compile Hints — 주석 한 줄로 JavaScript 시작 속도를 630ms 줄이는 법

Chrome 136에 도입된 V8의 Explicit Compile Hints 기능으로 JavaScript 초기 로딩 성능을 개선하는 원리와 사용법을 분석한다.

V8성능 최적화

V8의 JSON.stringify가 2배 빨라졌다 — 6가지 최적화 기법 해부

V8 13.8(Chrome 138)에서 적용된 JSON.stringify 성능 개선의 기술적 배경과 6가지 핵심 최적화 전략을 분석한다.

V8JSON

Temporal API — JavaScript Date의 30년 묵은 저주가 풀린다

Chrome 144가 Temporal API를 정식 탑재하면서 JavaScript 날짜 처리의 새 시대가 열렸다.

TemporalJavaScript

Interop 2026 — 브라우저 전쟁이 끝나고 표준 전쟁이 시작됐다

Chrome, Safari, Firefox가 합의한 20개 웹 표준 집중 영역과 프론트엔드 개발자가 주목해야 할 핵심 기능을 정리한다.

InteropCSS