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 범위의 격리.