/ 우리는 이렇게 합니다 / CSS :has() 숨은 노하우
개발 · 숨은 노하우

JS 없이, 부모를 바꿉니다.

오랫동안 CSS 선택자는 한 방향으로만 흘렀습니다. 부모에서 자식으로요. “자식 상태에 따라 부모를 바꾸고 싶다”는 한마디에 우리는 늘 자바스크립트를 꺼냈죠. :has()가 그 규칙을 깼습니다. 이 글은 읽는 글이 아니라 눌러보는 글입니다. 아래 체크박스, 진짜로 부모를 바꿉니다.

한 줄 직답

CSS :has()는 “이 요소가 특정 자식·형제를 가지고 있으면”을 검사하는 선택자입니다. 덕분에 체크·포커스·빈 값 같은 자식의 상태에 부모를 자바스크립트 없이 반응시킬 수 있고, 2023년 후반부터 모든 모던 브라우저에서 동작합니다.

요약

  • :has()는 “관계 선택자” — 자식·형제의 상태를 보고 부모/앞 형제를 고를 수 있다.
  • 체크·포커스·:invalid·빈 값에 자바스크립트 한 줄 없이 반응한다.
  • 토글 상태 관리 코드(이벤트 리스너·클래스 토글)가 통째로 사라져 유지보수가 가볍다.
  • 2023년 후반부터 Chrome·Edge·Safari·Firefox 안정 버전 전부 지원 — 구형만 폴백.

제가 프런트엔드를 처음 배울 때 가장 답답했던 게 이거였습니다. 카드 안에 체크박스가 있고, 체크하면 카드 전체에 테두리를 두르고 싶다. 그런데 CSS로는 안 됐어요. 선택자가 부모에서 자식으로만 내려갔거든요. 그래서 매번 change 이벤트를 달고, 부모를 찾아 올라가서 클래스를 붙였다 뗐다 했습니다. 작은 토글 하나에 자바스크립트 십수 줄이 붙었죠. :has()는 그 십수 줄을 CSS 한 줄로 돌려놨습니다.

그래서, 직접 눌러보세요

설명보다 빠릅니다. 아래 박스를 체크하면 무슨 일이 일어나는지 보세요. 자바스크립트는 단 한 줄도 없습니다.

Live · 숨은 노하우 · CSS :has()

체크박스 하나로 부모가 바뀝니다

아래 박스를 체크하면 JS 없이, 순수 CSS만으로 부모 카드가 반응합니다.

// .has-card:has(input:checked) { ... } — JS 한 줄 없음

그래서 :has()가 정확히 뭘 하는 건가요?

:has()는 “관계 선택자(relational selector)”입니다. 괄호 안에 적은 조건을 가지고 있는 요소를 고릅니다. 위 위젯의 규칙은 이렇게 생겼습니다.

.has-card:has(input:checked) { border-color: var(--signal); background: ...; }

해석하면 “input:checked를 가진 .has-card를 골라라”입니다. 체크박스는 카드의 자식인데, 그 자식의 상태가 부모인 카드를 바꿉니다. 예전엔 절대 못 하던 일이에요.

왜 이게 ‘부모 선택자’라고 불리나요?

오랫동안 개발자들이 가장 원했던 게 “부모 선택자”였습니다. 자식을 보고 부모를 고르는 기능이요. :has()는 그걸 포함합니다. 더 정확히는 방향이 자유로운 선택자라, 부모뿐 아니라 앞 형제도 고를 수 있습니다. 예를 들어 label:has(+ input:focus)는 “바로 뒤 input이 포커스된 label”을 고릅니다. 라벨이 input보다 앞에 있어도, 뒤 요소의 상태로 앞 요소를 꾸밀 수 있다는 뜻입니다.

제가 실무에서 실제로 쓰는 모양은 이렇게 짧습니다. 카드 선택과 폼 검증을 자바스크립트 없이 처리하는 핵심 규칙만 모아봤습니다.

CSS · :has() 실무 규칙
/* 1) 카드 안 체크박스가 켜지면 부모 카드를 강조 */
.plan:has(input:checked){
  border-color: var(--signal);
  background: color-mix(in srgb, var(--signal) 8%, transparent);
}

/* 2) 값을 입력한 뒤 형식이 틀렸을 때만 라벨을 빨갛게 */
.field:has(input:invalid:not(:placeholder-shown)) label{ color: crimson; }

/* 3) 항목이 비었을 때만 안내 문구 보이기 */
.list:not(:has(li)) .empty-note{ display: block; }

/* 4) 지원 브라우저에서만 얹는 점진적 향상 */
@supports selector(:has(*)){
  .has-card:has(input:checked){ outline: 2px solid var(--signal); }
}

네 줄짜리 규칙이 예전엔 전부 이벤트 리스너와 클래스 토글로 채워야 했던 자리입니다.

실무에서 이걸 어디에 쓰나요?

1. 카드 선택 UI

요금제 카드, 옵션 선택처럼 “고른 항목을 강조”하는 화면. 라디오/체크를 카드 안에 넣고 .plan:has(input:checked)로 선택된 카드만 테두리·배경을 바꿉니다. 위 위젯이 바로 이 패턴입니다.

2. 폼 검증 표시

입력 전에는 조용히, 손댄 뒤 형식이 틀렸을 때만 빨간 표시. .field:has(input:invalid:not(:placeholder-shown)) label { color: crimson; } 한 줄이면 됩니다. “비었을 때부터 빨갛게 떠서 거슬리는” 흔한 실수를 자바스크립트 없이 피합니다. 이 얘기는 폼 검증 담당 글에서 더 깊이 다룹니다.

3. 메뉴·레이아웃 조건부

“드롭다운이 열려 있으면 헤더 배경을 어둡게”, “이미지가 있는 카드만 그리드를 2열로” 같은 조건부 레이아웃. nav:has(.menu[open]), .card:has(img)처럼 구조 자체를 조건으로 삼을 수 있습니다.

4. 빈 값 처리

.list:has(li)는 항목이 하나라도 있을 때만 적용됩니다. 반대로 .list:not(:has(li))로 “비었을 때만 안내 문구 보이기”를 만들 수 있어, 빈 상태(empty state)를 자바스크립트 없이 그립니다.

브라우저는 어디까지 지원하나요?

2023년이 분기점이었습니다. Safari와 Chrome/Edge가 먼저 기본 지원을 켰고, Firefox가 2023년 후반 121 버전에서 합류하면서 모든 주요 모던 브라우저의 안정 버전이 :has()를 지원하게 됐습니다. 즉 지금 최신 브라우저를 쓰는 사용자에게는 사실상 전부 동작합니다. 변수는 업데이트가 끊긴 구형 브라우저뿐입니다.

지원 안 되는 브라우저는 어떻게 하나요?

점진적 향상(progressive enhancement)으로 설계합니다. 핵심은 “:has()가 없어도 화면이 멀쩡히 작동해야 한다”입니다. 기본 상태를 먼저 만들고, :has() 규칙은 그 위에 얹는 ‘보너스’로 둡니다. 분기를 명시하고 싶으면 이렇게 감쌉니다.

@supports selector(:has(*)) { .has-card:has(input:checked) { ... } }

지원하는 브라우저는 더 나은 반응을 받고, 안 되는 브라우저는 깨지지 않은 기본 화면을 봅니다. 어느 쪽도 손해 보지 않는 구조입니다.

코드량과 유지보수, 진짜 차이가 큰가요?

큽니다. 자바스크립트로 부모를 토글하려면 보통 (1) 요소를 잡고, (2) 이벤트 리스너를 달고, (3) 부모를 거슬러 올라가 클래스를 붙였다 뗐다 하고, (4) 동적으로 추가된 요소까지 신경 써야 합니다. 이 네 단계가 전부 사라집니다. 상태가 DOM에 이미 있으면(체크·포커스·invalid·존재 여부) CSS가 알아서 읽습니다. 리스너가 없으니 메모리 누수·해제 누락 같은 버그도 원천적으로 줄어듭니다.

항목JS로 부모 토글:has() 한 줄
코드량이벤트 리스너 + 클래스 토글 십수 줄CSS 선택자 한 줄
성능리스너·리렌더 비용, 누수 위험브라우저 네이티브, 좁은 범위면 부담 미미
유지보수동적 요소·해제까지 추적상태가 DOM에 있으면 자동 반영

이런 디테일을 아는 사람이, 사이트 전체를 만듭니다

:has() 같은 도구는 “자바스크립트로 떡칠하지 않아도 되는 지점”을 알아보는 안목에서 나옵니다. 코드가 줄면 버그가 줄고, 사이트가 가벼워지고, 다음 사람이 고치기 쉬워집니다. Findable에서는 이런 판단이 기본값입니다.

다른 담당자와의 연결

이 노하우는 혼자 굴러가지 않습니다. 우리가 어떤 원칙으로 코드를 짜는지는 개발은 이렇게 합니다에서, :has()로 만드는 검증 UX의 디테일은 폼 검증 담당 글에서, 그리고 “왜 가벼운 코드가 결국 빠른 사이트인가”는 성능 담당 글에서 이어집니다.

:has()는 정말 자바스크립트 없이 부모를 바꾸나요?
네. .card:has(input:checked) 처럼 쓰면 카드 안의 체크박스가 켜졌을 때 카드(부모)에 스타일이 적용됩니다. 이전에는 CSS 선택자가 자식 방향으로만 내려갔기 때문에 부모를 바꾸려면 자바스크립트로 클래스를 붙여야 했습니다. :has()는 그 일을 CSS 한 줄로 대신합니다.
브라우저 지원은 어디까지 되나요?
2023년 후반부터 Chrome, Edge, Safari, Firefox 등 모든 주요 모던 브라우저의 안정 버전에서 :has()가 동작합니다. Firefox는 가장 늦게 121 버전에서 기본 활성화됐습니다. 즉 최신 브라우저를 쓰는 사용자에게는 사실상 전부 지원됩니다. 구형 브라우저만 폴백이 필요합니다.
:has()를 지원하지 않는 브라우저는 어떻게 처리하나요?
점진적 향상으로 설계합니다. :has() 없이도 화면이 멀쩡히 동작하게 만든 다음, :has() 규칙을 그 위에 얹습니다. 지원하는 브라우저는 더 나은 반응을 받고, 안 되는 브라우저는 기본 상태를 그대로 봅니다. @supports selector(:has(*)) 로 감싸면 분기도 명시적으로 할 수 있습니다.
:has()는 성능에 부담이 되나요?
실무 범위의 단순한 규칙(체크 상태·빈 값·포커스 반응)에서는 체감 부담이 없습니다. 다만 :has(.a) ~ .b 처럼 형제까지 멀리 검사하는 복잡한 셀렉터를 페이지 전체에 남발하면 비용이 늘 수 있으니, 범위를 좁은 컨테이너로 한정하는 것이 좋습니다.
폼 검증 표시에 :has()를 어떻게 쓰나요?
.field:has(input:invalid:not(:placeholder-shown)) 처럼 쓰면 사용자가 값을 입력한 뒤 형식이 틀렸을 때만 빨간 표시를 띄울 수 있습니다. 입력 전에는 조용히 두고, 손댄 뒤에만 알려주는 검증 UX를 자바스크립트 없이 만듭니다.

이런 디테일로 사이트를 만듭니다

코드를 줄일 줄 아는 팀이 당신의 홈페이지를 만들면 어떨까요. 무료 진단으로 시작하세요.

무료 진단 받기

이 글의 위젯은 이 페이지에서 실제로 동작하는 순수 CSS 코드입니다(:has() 기반, 자바스크립트 0). 브라우저 지원 범위는 모던 브라우저 안정 버전 기준이며, 구형 환경에는 점진적 향상 폴백을 권장합니다. 날조된 사례·수치는 사용하지 않았습니다.