CSS에서 부모 요소를 선택하는 건 오랫동안 불가능했다. "자식이 특정 상태일 때 부모의 스타일을 바꾸고 싶다"는 요구사항이 나오면 JavaScript를 꺼내야 했다. :has() 선택자가 이걸 바꿨다.
:has()는 말 그대로 "~을 가진 요소"를 선택한다. A:has(B)는 "B를 자식으로 가진 A"를 선택한다.
/* 이미지를 포함한 카드만 패딩을 다르게 */
.card:has(img) {
padding: 0;
}
/* 체크된 체크박스를 가진 label */
label:has(input:checked) {
background-color: #e0f2fe;
border-color: #2563eb;
}2025년 기준으로 모든 주요 브라우저에서 지원한다. Chrome 105+, Firefox 121+, Safari 15.4+. 실무에서 써도 되는 수준이다.
폼 유효성 시각화
이전에는 입력 필드의 유효성에 따라 부모 요소의 스타일을 바꾸려면 JavaScript가 필요했다.
/* input이 invalid일 때 감싸는 div의 스타일 변경 */
.form-group:has(input:invalid:not(:placeholder-shown)) {
--border-color: #ef4444;
--label-color: #ef4444;
}
.form-group:has(input:valid:not(:placeholder-shown)) {
--border-color: #22c55e;
--label-color: #22c55e;
}
.form-group:has(input:focus) {
--border-color: #2563eb;
--label-color: #2563eb;
}
.form-group {
border: 2px solid var(--border-color, #e5e7eb);
border-radius: 8px;
padding: 12px;
}
.form-group label {
color: var(--label-color, #374151);
font-size: 0.875rem;
}<div class="form-group">
<label for="email">이메일</label>
<input id="email" type="email" placeholder="이메일을 입력하세요" required />
</div>:not(:placeholder-shown)은 사용자가 뭔가 입력했을 때만 유효성 스타일을 보여주기 위해서다. 빈 필드에서부터 빨간색이 보이면 사용자 경험이 안 좋으니까.
빈 상태 처리
리스트가 비었을 때 "데이터가 없습니다" 메시지를 보여주는 것도 CSS만으로 가능하다.
.list:not(:has(.list-item)) .empty-state {
display: block;
}
.list:has(.list-item) .empty-state {
display: none;
}<ul class="list">
<!-- 아이템이 없으면 empty-state가 보인다 -->
<li class="empty-state">표시할 항목이 없습니다</li>
</ul>이전에는 JavaScript에서 배열 길이를 체크하고 조건부 렌더링을 해야 했던 패턴이다. 물론 React에서는 여전히 조건부 렌더링이 자연스럽지만, 서버에서 내려주는 정적 HTML이나 CMS 기반 페이지에서는 이 CSS 패턴이 유용하다.
카드 레이아웃
카드의 내용물에 따라 레이아웃을 다르게 가져가는 패턴.
/* 이미지가 있는 카드: 이미지 위 + 텍스트 아래 */
.card:has(.card-image) {
display: grid;
grid-template-rows: 200px 1fr;
}
/* 이미지 없는 카드: 텍스트만 */
.card:not(:has(.card-image)) {
display: flex;
flex-direction: column;
justify-content: center;
padding: 24px;
}
/* 태그가 3개 이상인 카드: 태그 영역을 넓게 */
.card:has(.tag:nth-child(3)) .tag-container {
flex-wrap: wrap;
gap: 4px;
}네비게이션 활성 상태
현재 활성화된 링크가 있는 네비게이션 그룹의 스타일을 바꾸는 것.
/* 활성 링크를 가진 nav-group에 배경색 */
.nav-group:has(.nav-link.active) {
background-color: #f0f9ff;
border-radius: 8px;
}
/* 활성 링크가 있으면 그룹 제목도 강조 */
.nav-group:has(.nav-link.active) .nav-group-title {
color: #2563eb;
font-weight: 600;
}형제 선택과의 조합
:has()는 자식뿐 아니라 형제 선택자와도 조합할 수 있다.
/* 다음 형제에 에러 메시지가 있는 input */
input:has(+ .error-message) {
border-color: #ef4444;
}<input type="email" />
<span class="error-message">올바른 이메일을 입력하세요</span>+는 바로 다음 형제를 의미한다. input:has(+ .error-message)는 "바로 다음 형제가 .error-message인 input"을 선택한다.
테이블 인터랙션
/* 체크박스가 체크된 행 강조 */
tr:has(input[type="checkbox"]:checked) {
background-color: #eff6ff;
}
/* 체크된 행이 하나라도 있으면 bulk action 바 표시 */
table:has(tr input[type="checkbox"]:checked) + .bulk-actions {
display: flex;
}
/* 모든 체크박스가 체크되면 헤더 체크박스 스타일 변경 */
thead:has(input:checked) {
background-color: #dbeafe;
}어드민 대시보드에서 자주 쓰는 패턴이다. 행을 선택하면 배경색이 바뀌고, 하나라도 선택하면 "선택 삭제" 같은 bulk action UI가 나타나는 것. 이전에는 JavaScript로 상태를 관리하고 클래스를 토글해야 했다.
다크 모드와의 조합
/* 시스템 다크 모드에서 이미지가 있는 카드의 이미지 밝기 조절 */
@media (prefers-color-scheme: dark) {
.card:has(img) img {
filter: brightness(0.85);
}
}
/* data-theme으로 다크 모드를 관리하는 경우 */
[data-theme="dark"] .card:has(img) img {
filter: brightness(0.85);
}주의할 점
성능. :has()는 다른 선택자에 비해 브라우저가 처리하는 비용이 크다. 특히 *:has(.something) 같이 전체 선택자와 결합하면 모든 요소를 검사해야 한다. 범위를 좁혀서 쓰는 게 좋다.
/* 피해야 할 패턴 */
*:has(.error) { /* 모든 요소 검사 */ }
/* 범위를 좁히자 */
.form-group:has(.error) { /* .form-group만 검사 */ }:has()는 forgiving selector list를 사용한다. :has() 안에 유효하지 않은 선택자가 있어도 전체 규칙이 무효화되지 않는다. 이건 장점이기도 하지만, 오타를 발견하기 어려울 수도 있다.
:has()가 나오기 전에는 "CSS로 부모를 선택할 수 없다"가 프론트엔드 면접 단골 질문이었다. 이제 그 답이 바뀌었다. 바뀐 게 단순히 선택자 하나가 추가된 게 아니라, CSS가 할 수 있는 것의 범위가 확장된 거다. JavaScript로 클래스를 토글하던 코드, 상태를 관리하던 코드 중에서 :has()로 대체할 수 있는 게 생각보다 꽤 있다. 기존 코드를 쭉 훑어보면서 "이거 :has()로 되지 않나?" 하고 찾아보면 놀라울 거다.
