03 — :has() Selector
답하는 질문: CSS는 왜 28년 동안 부모 선택자가 없었나?
:has()는 그 한계를 어떻게 풀었나?
한 줄 답
:has(selector)는 자식·후손·형제의 존재 여부로 자기 자신을 선택하는 의사 클래스다. 부모 선택자 또는 관계 기반 선택자라 불린다. 1996년부터 요청되었으나 순방향 단방향 매칭 가정 때문에 28년간 미뤄지다 2023년 Baseline로 합류. CSS의 가장 큰 한계가 풀린 사건.
Why — 28년의 부재
1) 1996년의 설계 결정
CSS1 시절, 셀렉터 매칭은 right-to-left 순방향 단일 패스로 설계됐다 — 성능 보장을 위해.
.foo .bar { ... }브라우저는 모든 .bar를 찾고 → 조상에 .foo 있는지만 확인. 역방향 탐색은 하지 않았다.
부모 선택자(.foo < .bar 같은 가상 문법)는 역방향 탐색이 필요 — DOM 트리 전체 재평가를 매 변경마다 해야 한다. 1996년 하드웨어로는 불가능했다.
2) jQuery .has()의 인기 — 14년의 우회
$('.card').has('.warning') // CSS로 못 하던 일을 JS로이 문법이 인기였다는 것 자체가 수요가 있다는 증거였다. CSSWG는 1998년 jQuery보다도 먼저 :has()를 Selectors Level 2 Working Draft에 넣었지만 — Non-implementable로 분류해 30년 가까이 보류.
3) 2022년의 돌파 — Igalia의 후원
Safari 15.4(2022)가 처음 구현, Chrome 105(2022)가 뒤따랐다. Firefox는 121(2023)에서 합류. 핵심 변화는:
- Invalidation trees — DOM 변경 시 영향받는 노드만 재평가하는 알고리즘 성숙.
- GPU 시대의 성능 여유 — 1996년의 우려가 더 이상 결정적이지 않음.
2023년 7월, Web Platform Status가 Baseline 2023으로 선언. 28년 만의 합류.
How — 어떻게 매칭되나
상대 선택자의 암묵적 콤비네이터는 descendant — :has(.warning)은 :has(.descendant .warning)이 아니라 어떤 후손이든 .warning이면 true.
What — 표현 가능한 패턴
1) 부모 선택 — 자식의 존재
/* 경고 메시지가 있는 카드 강조 */
.card:has(.warning) {
border: 2px solid red;
}
/* 빈 카드(자식 없음) */
.card:has(*) { /* 자식 있음 */ }
.card:not(:has(*)) { /* 비어있음 */ }2) 형제 선택 — sibling combinator
/* 다음 형제가 h2인 p 강조 */
p:has(+ h2) {
margin-bottom: 0;
}
/* 라벨이 체크된 input을 가진 form-group */
.form-group:has(input:checked) {
background: #e8f4ff;
}3) 상태 전파 — :focus-within의 일반화
/* 폼이 invalid 상태면 submit 비활성화 시각 */
form:has(:invalid) .submit {
opacity: 0.5;
}
/* 다크 모드 토글이 체크된 페이지 */
html:has(#dark-toggle:checked) {
--bg: #111;
}4) :not(:has(...)) — 부재 기반 선택
/* 이미지가 없는 article은 padding 다르게 */
article:not(:has(img)) {
padding-left: 1rem;
}5) 다른 :pseudo와 결합
/* 자식 중 첫 .featured가 있으면 컨테이너 강조 */
.list:has(> .item.featured:first-child) {
background: #fff8dc;
}
/* 어떤 input이든 focus 중인 form */
form:has(:focus) {
outline: 2px solid blue;
}6) 중첩 :has() — Chrome 120+, Safari 17+
.card:has(.section:has(.warning)) { ... }주의: 깊은 중첩은 성능 비용 큼.
What-if — 잘못 쓰면
Case 1: 성능 — 큰 트리에서 매번 재평가
:has()는 어떤 자식이든 변경되면 부모 재평가. 1000개 항목이 있는 리스트에서:
ul:has(li.active) { background: yellow; }li.active가 토글될 때마다 ul의 셀렉터 재평가. 작은 트리에선 무시 가능, 큰 트리에선 jank.
완화:
- 앵커를 좁히기 —
.specific-list:has(...)처럼 명시 클래스로 시작. :has(.warning)처럼 자주 토글되는 클래스보다는 덜 자주 변하는 속성에 의존.- 매번 재평가가 정말 필요한지 검토 — 상태가 부모로 올라와도 되면 JS로 부모 클래스를 토글하는 게 더 쌀 수 있음.
Case 2: :has() 안의 :has() 무한 깊이
body:has(*:has(*:has(...))) { ... }브라우저는 Selector Level 4 명세에 따라 forgiving하게 처리하지만, 성능 저하 보장. 1-depth 권장.
Case 3: Specificity 폭발
.card:has(.foo.bar.baz) { ... }:has()의 specificity는 내부 셀렉터의 specificity — .foo.bar.baz라면 (0,3,0)이 추가됨. 외부에서 덮어쓰기 어려움.
해결 — :where()로 감싸 specificity 0:
.card:has(:where(.foo, .bar, .baz)) { ... } /* specificity = .card만 */Case 4: Firefox 121 이전 사용자
2024년 1월 이전 Firefox는 :has() 미지원 — @supports selector(:has(*))로 가드:
@supports selector(:has(*)) {
.card:has(.warning) { border-color: red; }
}
/* 폴백: JS로 클래스 토글 */2026-05 기준 95% 이상 지원이지만 모바일 인앱 브라우저 일부는 여전히 구버전 — 검토 필요.
Case 5: form 검증 시 무한 루프 위험?
form:has(:invalid) input { border-color: red; }이론적으로 입력이 invalid → border 변경 → 다시 invalid 평가? 브라우저는 스타일 변경이 :invalid 상태에 영향 주지 않는다는 것을 컨테인먼트로 보장 — 안전.
Insight — :has()는 CSS의 사고 방향을 바꾼다
CSS의 28년은 “부모 → 자식” 단방향이었다. :has()는 처음으로 “자식 → 부모” 양방향을 허락한다.
이는 단순히 한 셀렉터의 추가가 아니다 — CSS가 조건부 적응을 할 수 있게 된 사건.
/* 이전: JS로 article에 .has-image 클래스를 붙임 */
article.has-image { ... }
/* 이후: CSS만으로 자율 */
article:has(img) { ... }리액트 컴포넌트의 if-else 분기 중 *80%*는 자식의 존재/상태로 부모 클래스를 결정하는 것이었다. :has()는 그 80%를 CSS로 되돌려준다.
Bramus Van Damme(Chrome DevRel)의 2023 강연 “The CSS of the Future is Already Here” 에서 그는 이를 “CSS reclaiming territory from JavaScript” 라고 불렀다.
Container Queries가 컴포넌트의 공간을, :has()가 컴포넌트의 상태를 CSS로 되찾았다.
요약 + Mermaid
:has(selector)는 자식/형제의 존재로 자기를 선택 — 부모 선택자.- 28년의 부재는 역방향 매칭의 성능 우려 때문.
- 2023 Baseline 합류 — Chrome 105, Safari 15.4, Firefox 121.
- 큰 트리에서 jank 위험 — 앵커를 좁히기.
:where()로 specificity 폭발 방지.
다음: 04-scope — DOM 범위의 격리.