CSS :has() — 안에서 무슨 일이 일어나는지 아는 부모
CSS :has() — 안에서 무슨 일이 일어나는지 아는 부모
폼 필드가 하나 있습니다. 레이블과 그 안의 input을 담은 <div class="field">입니다. 요구 사항은 단순해 보입니다 – input이 유효하지 않을 때, input 자체만이 아니라 컨테이너 전체가 빨간 테두리를 얻고 레이블 옆에 경고 아이콘이 나타나야 합니다. .field input:invalid를 시도해 보지만 벽에 부딪힙니다. 이 선택자는 빨간 테두리를 원하는 것이 input이라면 그것을 스타일링할 것입니다. 하지만 여러분이 스타일링하고 싶은 것은 그 input의 부모인 .field이며, 그것도 내부에서 일어나는 일을 근거로 그렇게 하고 싶은 것입니다. 그리고 문득 깨닫게 됩니다. CSS는 수십 개의 결합자, 의사 클래스, 속성 선택자를 갖추고 있음에도 25년의 역사 내내 이것을 할 방법을 한 번도 제공한 적이 없다는 사실을요.
이것은 여러분의 CSS 지식에 빠진 부분이 아닙니다. CSS1부터 2022년까지 계속 유지되어 온, 선택자 아키텍처의 근본적인 특징입니다.
결합자가 항상 한쪽 방향만 바라보는 이유
CSS의 모든 결합자 – 공백(자손), >(직계 자식), +(인접 형제), ~(일반 형제) – 는 두 선택자 사이의 관계를 표현하지만, 항상 결합자 오른쪽에 있는 요소가 스타일링됩니다. .field input은 .field 안의 input을 스타일링합니다. .field ~ .error는 .field의 형제인 .error를 스타일링합니다. 방향은 언제나 같습니다. 맥락에서 대상으로, 결코 그 반대가 아닙니다. CSS 엔진은 .field를 만났을 때 "안을 들여다보고" 그것을 근거로 .field 자체의 스타일 결정을 바꾸는 내장 메커니즘을 갖고 있지 않습니다.
개발자들은 수년간 두 가지 방식으로 이를 우회해 왔고, 둘 다 실질적인 대가가 따랐습니다. 첫 번째는 JavaScript로 input/blur 이벤트를 수신해 부모에게 수동으로 .field--invalid 클래스를 붙이는 방식입니다. 두 번째는 더 영리하지만 취약한, 이른바 체크박스/라디오 해킹입니다. ~를 이용해 :checked 상태를 근거로 형제를 스타일링하는 방식인데, 이는 요소들이 실제 폼이 요구하는 것처럼 중첩되어 있지 않고 평평한 DOM 구조에서 진짜 형제일 때만 작동합니다.
jsx
// :has() 이전의 우회법 – 작동은 하지만, 순수하게 HTML 상태의// 시각적 결과일 뿐인 것을 위해 JS가 필요하다import { useState } from "react";const FormField = ({ label, ...inputProps }) => {const [isInvalid, setIsInvalid] = useState(false);return (<div className={`field ${isInvalid ? "field--invalid" : ""}`}><label>{label}</label><input{...inputProps}onBlur={(e) => setIsInvalid(!e.target.validity.valid)}/></div>);};
:has()가 이 코드를 없애는 이유는 더 영리한 트릭이어서가 아니라, CSS 역사상 최초로 요소가 자기 자신의 내부에 대해 물어볼 수 있게 해주는 관계형 의사 클래스로서 문제를 근원에서 해결하기 때문입니다.
:has()가 실제로 작동하는 방식: 자손이 아니라 요소 자신에 고정된 선택자
여기서 핵심적인 사고의 전환이 필요합니다. .field:has(input:invalid)는 input을 스타일링하지 않습니다. 이는 .field를 스타일링합니다 – 의사 클래스가 붙어 있는 바로 그 요소를 말이죠 – 다만 그 내부 어딘가에 괄호 안 선택자와 일치하는 요소가 존재한다는 조건 아래에서입니다. "고정 지점"(anchor element)이 되는 요소는 항상 그대로 유지됩니다. :has()는 그저 그 요소가 애초에 일치 판정을 받을지 말지를 결정할 뿐입니다.
css
.field:has(input:invalid) {border-color: #c62828;}.field:has(input:invalid) .field__icon {visibility: visible;}
기본적으로 :has() 안의 선택자는 일반 공백 결합자처럼 어떤 깊이에서든 임의의 자손을 찾습니다. 하지만 괄호 안에서 결합자를 직접 사용해 일반 CSS와 똑같은 방식으로 그 관계를 좁힐 수 있습니다.
css
/* 임의의 자손이 아니라 직계 자식만 */.card:has(> img) {grid-template-columns: 120px 1fr;}/* 형제로서 .error-message가 바로 뒤따르는 요소 */.field:has(+ .field-hint) {margin-bottom: 4px;}
이는 :has()가 하나의 새로운 트릭이 아니라 일반화라는 것을 보여줍니다 – 이전에 결합자로 표현할 수 있었던 모든 관계를, 오직 "앞으로"만이 아니라 "안으로" 또는 "뒤로" 향하는 방향으로도 CSS에서 표현할 수 있게 해줍니다.
실전 예제: 스스로 유효하지 않음을 아는 폼 필드
이를 완전하고 접근성 있는 컴포넌트로 합쳐 보겠습니다. 중요한 세부 사항이 있습니다. :invalid는 required 속성이 있는 빈 필드가 로드되는 즉시 그 자체로 발동합니다 – 사용자가 아직 아무것도 입력하지 않았는데도 비어 있고 손대지 않은 폼에 빨간 테두리가 생기게 됩니다. 이는 :not(:placeholder-shown)으로 해결할 수 있습니다. 사용자가 실제로 뭔가를 입력해 남긴 경우에만 필드가 일치하게 만들어 줍니다.
jsx
const FormField = ({ label, id, ...inputProps }) => (<div className="field"><label htmlFor={id}>{label}</label><input id={id} {...inputProps} /><svg className="field__icon" aria-hidden="true" viewBox="0 0 20 20"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 12H9v-2h2v2zm0-4H9V5h2v5z" /></svg></div>);export default FormField;
css
.field {position: relative;border: 1px solid #ccc;border-radius: 8px;padding: 8px 12px;transition: border-color 0.15s ease;}.field__icon {position: absolute;right: 12px;top: 50%;transform: translateY(-50%);width: 18px;fill: #c62828;visibility: hidden;}/* 필드가 "손을 탔고"(무언가 입력됨) 동시에 유효하지 않을 때 – 그때만 반응한다 */.field:has(input:not(:placeholder-shown):invalid) {border-color: #c62828;background: #fff5f5;}.field:has(input:not(:placeholder-shown):invalid) .field__icon {visibility: visible;}/* 긍정적인 확인 표시도 오류 표시만큼 중요하다 */.field:has(input:not(:placeholder-shown):valid) {border-color: #2e7d32;}
외형을 담당하는 JavaScript 코드는 단 한 줄도 없습니다. 검증 상태는 이미 브라우저에 네이티브로 존재합니다 – :valid/:invalid는 CSS3 시절부터 있었습니다 – :has()는 그저 마침내 그 기존 상태를 input에서 그 컨테이너로, 시각적으로 항상 필요했던 바로 그곳으로 옮길 수 있게 해준 것뿐입니다.
두 번째 패턴: 자신이 무엇을 담고 있는지 아는 컴포넌트
같은 메커니즘이 완전히 다른 종류의 문제도 해결합니다 – 특정 콘텐츠의 존재 여부에 따라 달라지는 레이아웃을, 이를 미리 알려주는 prop 없이 처리하는 문제입니다.
css
/* 이미지가 있는 카드는 2열 레이아웃을, 이미지가 없는 카드는 텍스트 전체 너비를 얻는다 */.card:has(> img) {display: grid;grid-template-columns: 96px 1fr;gap: 12px;}/* 섹션 제목은 섹션에 실제로 부제목이 있을 때만 아래쪽 여백을 갖는다 */.section:has(> .section__subtitle) .section__title {margin-bottom: 4px;}/* 항목이 하나도 없는 리스트는 빈 공간 대신 빈 상태를 보여준다 */.product-list:not(:has(li)) {display: block;}.product-list:not(:has(li))::after {content: "조건에 맞는 상품이 없습니다.";color: #666;}
:has()가 없다면 이 각각의 경우는 부모 컴포넌트가 수동으로 설정하는 prop(hasImage, isEmpty)이 필요하거나, JSX에서 배열 길이를 확인하고 조건부로 별도 클래스를 렌더링해야 했을 것입니다. :has()는 컴포넌트가 외부로부터 정보를 전달받는 대신 스스로 자신의 콘텐츠를 인식하고 반응할 수 있게 해줍니다 – 이는 컨테이너 쿼리 글에서 다룬, 자신의 컨테이너를 스스로 아는 상품 카드와 정확히 같은 사고방향입니다. 수동으로 전달되는 상태는 줄어들고, 구조 자체에서 곧바로 도출되는 로직은 늘어납니다.
함정과 좋은 습관
:has()의 특이성은 0이 아니라 내부에서 가장 특이성이 높은 선택자의 특이성입니다..field:has(input:invalid)는 클래스 두 개와 의사 클래스를 합친 만큼의 특이성을 가집니다 –:has()가 특이성에 "포함되지 않는다"고 기대하지 마십시오. 실제로는 나중에 작성된 더 단순한 규칙을 규칙적으로 이겨버립니다.- 결합자 없이 깊이 중첩되고 범위가 넓은
:has()는 비용이 클 수 있습니다..app:has(.some-deeply-nested-element)는 이론적으로 그 내부에서 DOM이 바뀔 때마다 엔진이.app의 전체 하위 트리를 고려하게 만듭니다. 최신 엔진(Chromium, WebKit)은 이른바 무효화 집합(invalidation set)을 통해 이를 최적화하여 무작정 모든 것을 다시 계산하지는 않지만, 효과는 구체적인 선택자와 트리 크기에 따라 달라집니다. 가능하다면 기본적인 깊은 탐색 대신 결합자(>, 직계 자식)로 관계를 좁히십시오. :has()는:focus-within을 대체하지 않습니다. "자손이 포커스를 가지면 부모가 반응한다"는 것만 필요하다면,:focus-within이 오래전부터 존재하며 계산 비용이 더 저렴하고 더 읽기 쉽습니다 –:has(:focus)도 사실상 같은 효과를 내지만, 이미 특화된 도구가 있는데 더 일반적인 도구를 쓸 이유는 없습니다.- 브라우저 지원은 더 이상 문제가 아닙니다.
:has()는 마지막으로 자리 잡은 큰 조각이었습니다 – Safari는 15.4(2022년 3월)부터, Chrome은 105(2022년 8월)부터 지원했고, Firefox가 121 버전(2023년 12월)으로 가장 마지막에 합류했습니다. 그 이후로는 어떤 새 프로젝트에서든@supports없이 안전하게 사용할 수 있습니다.
마무리
25년 동안 CSS는 오직 한 방향으로만 향하는 관계 – 맥락에서 대상으로, 부모에서 자식으로, 앞선 것에서 뒤따르는 것으로 – 만을 표현할 수 있게 해주었습니다. :has()는 같은 아이디어의 또 다른 변형이 아니라, 요소가 자기 자신의 내부에 대해 물어보고 그곳에서 발견한 것 – 필드의 검증 오류, 카드 안 이미지의 존재, 리스트에 항목이 없다는 사실 – 에 반응할 수 있게 해주는 최초의 의사 클래스입니다. 이는 이전에 JavaScript나 위에서 수동으로 전달되는 prop으로 처리해야 했던 결정 전체를, 원래 있어야 할 자리 – HTML 구조와 그 구조를 기술하는 CSS 규칙 – 로 되돌려 놓습니다.