CSS :has() 선택자 실전 가이드: 자바스크립트 없이 부모를 선택하는 법
체크하면 카드가 강조되고, 입력이 틀리면 폼 전체가 반응하고, 별점에 마우스를 올리면 앞의 별까지 칠해진다. 예전엔 자바스크립트가 필요했던 일을 CSS :has() 한 줄로 해결하는 실전 예제를 직접 눌러보며 정리했다.
CSS의 오랜 숙원, "부모 선택자"
CSS 선택자는 오랫동안 아래 방향으로만 내려갈 수 있었다. .card img처럼 부모 안의 자식은 고를 수 있어도, "이미지를 가진 카드"처럼 자식을 기준으로 부모를 고르는 건 불가능했다.
그래서 이런 코드가 흔했다.
// 체크박스가 체크되면 부모 카드에 클래스를 붙이는 코드
checkbox.addEventListener("change", (e) => {
e.target.closest(".card").classList.toggle("is-selected", e.target.checked);
});
:has()가 나오면서 이 코드는 CSS 한 줄이 됐다.
.card:has(input:checked) {
border-color: orange;
}
A:has(B)는 "B를 포함하고 있는 A"를 선택한다. 괄호 안의 조건을 만족하는 바깥 요소에 스타일이 적용된다.
괄호 안에는 자손뿐 아니라 형제 관계도 쓸 수 있어서, 사실상 "조건부 선택자"처럼 쓸 수 있다.
| 쓰는 법 | 의미 |
|---|---|
| A:has(B) | 자손 중에 B가 있는 A |
| A:has(> B) | 바로 아래 자식으로 B가 있는 A |
| A:has(+ B) | 바로 다음 형제가 B인 A |
| A:has(~ B) | 뒤쪽 형제 중에 B가 있는 A |
| A:not(:has(B)) | B가 없는 A |
이제 실전 예제를 직접 눌러보면서 보자. 각 예제의 위쪽은 실제 화면이고, 아래 탭에서 코드를 볼 수 있다.
예제 1. 선택하면 강조되는 카드
요금제 선택 화면에서 자주 보는 UI다. 라디오 버튼을 고르면 그 카드 전체가 강조된다. 자바스크립트는 한 줄도 없다.
핵심은 마지막 규칙 하나다. input:checked를 가진 .plan을 고른다.
예제 2. 입력이 틀리면 폼 전체가 반응
:has()는 폼 상태와 특히 잘 어울린다. 아래 예제에서 이메일 칸에 아무 글자나 쓰고 다른 곳을 클릭해보자.
입력칸 하나의 상태로 라벨, 폼 테두리, 안내 문구, 버튼이 한꺼번에 바뀐다. 예전에는 입력 이벤트를 듣고 상태를 만들어서 각 요소에 클래스를 붙여야 했던 일이다.
:user-invalid는 사용자가 입력을 건드린 뒤에만 적용되는 의사 클래스다. :invalid를 쓰면 페이지에 들어오자마자 빈 칸이 전부 "틀렸다"고 표시되니, 에러 표시에는 :user-invalid가 더 자연스럽다.
예제 3. 앞쪽 형제 선택하기: 별점 UI
CSS의 ~, + 결합자는 뒤쪽 형제만 고를 수 있었다. 별점에 마우스를 올렸을 때 "그 별과 앞의 별들"을 칠하는 게 어려웠던 이유다. :has()를 쓰면 앞쪽도 고를 수 있다. 별 위에 마우스를 올려보자.
결과적으로 "마우스가 올라간 별까지" 칠해진다. 형제 관계로 직접 쓰면 이렇게도 표현할 수 있다.
/* 바로 다음 형제가 hover 중인 요소 = hover 된 요소의 바로 앞 형제 */
li:has(+ li:hover) {
transform: scale(1.1);
}
macOS Dock처럼 마우스를 올린 아이콘 양옆이 함께 커지는 효과를 이렇게 만든다.
예제 4. 개수에 따라 레이아웃 바꾸기
"항목이 4개 이상이면 2열로" 같은 조건도 CSS만으로 된다. 아래 버튼으로 항목을 늘리고 줄여보자. (항목 추가/삭제에만 자바스크립트를 썼고, 레이아웃 전환은 CSS가 한다.)
li:nth-child(4)가 존재하는지를 :has()로 묻는 방식이다. 이런 기법을 수량 쿼리(Quantity Query)라고 부른다.
예제 5. 페이지 전체 상태 바꾸기
:has()를 html이나 body에 걸면 페이지 어디에 있는 요소든 조건으로 쓸 수 있다.
/* 모달이 열려 있으면 뒤 페이지 스크롤을 막는다 */
body:has(dialog[open]) {
overflow: hidden;
}
/* 다크 모드 체크박스가 체크되면 페이지 전체 색을 바꾼다 */
html:has(#dark-mode:checked) {
color-scheme: dark;
background: #111;
color: #eee;
}
모달을 열 때마다 document.body.style.overflow = "hidden"을 넣고, 닫을 때 되돌리는 코드를 지울 수 있다.
쓰기 전에 알아둘 점
브라우저 지원
:has()는 Chrome·Edge 105, Safari 15.4, Firefox 121부터 지원된다. 2023년 말부터 주요 브라우저에서 모두 쓸 수 있다. 오래된 브라우저까지 챙겨야 한다면 @supports로 감싸서 지원할 때만 적용한다.
@supports selector(:has(a)) {
.plan:has(input:checked) { border-color: #f59e0b; }
}
주의할 점
| 주의할 점 | 설명 |
|---|---|
| :has() 안에 :has()는 못 쓴다 | .a:has(.b:has(.c))는 무효다 |
| 괄호 안에 의사 요소는 못 쓴다 | :has(::before)는 동작하지 않는다 |
| 너무 넓은 범위는 피한다 | body:has(*:hover)처럼 페이지 전체를 계속 검사하게 만드는 선택자는 성능에 부담이 된다. 범위를 좁힐수록 좋다 |
| 하나가 틀리면 규칙 전체가 무시된다 | :has() 안의 목록 중 하나라도 잘못된 선택자가 있으면 그 규칙 전체가 적용되지 않는다 |
정리: 왜 알아두면 좋은가
| 하고 싶은 것 | :has() 해법 |
|---|---|
| 체크된 항목의 카드 강조 | .card:has(input:checked) |
| 입력 에러 시 폼 전체 반응 | form:has(input:user-invalid) |
| 포커스된 입력칸의 라벨 강조 | .field:has(input:focus) label |
| 이미지가 있는 카드만 다른 레이아웃 | .card:has(img) |
| 앞쪽 형제 선택 | li:has(+ li:hover) |
| 개수에 따른 레이아웃 | .list:has(> li:nth-child(4)) |
| 모달 열림 시 스크롤 잠금 | body:has(dialog[open]) |
:has()의 진짜 가치는 "상태를 위해 클래스를 붙였다 뗐다 하는 자바스크립트"를 지울 수 있다는 데 있다. 상태는 이미 HTML에 있다. 체크됐는지(:checked), 입력이 틀렸는지(:user-invalid), 마우스가 올라가 있는지(:hover), 열려 있는지([open]). :has()는 그 상태를 CSS가 직접 읽게 해준다. 다음에 classList.toggle을 쓰려는 순간, CSS로 되지 않는지 먼저 떠올려보자.