폼 검증 표시는 대개 이런 구조로 되어 있다. 입력칸이 틀리면 입력칸 테두리만 빨개지는 게 아니라, 그걸 감싼 박스에 배경이 깔리고, 라벨 색이 바뀌고, 아래에 숨어 있던 오류 문구가 나타난다. 브라우저는 input:invalid 로 입력칸 자신은 골라 줬지만 그걸 감싼 div 나 옆에 있는 label 은 고를 방법이 없었다. CSS 선택자는 아래로만 내려가지 위로는 못 올라갔기 때문이다.
그래서 검증 스타일은 JS 몫이었다. Bootstrap 은 제출 버튼을 누를 때 form 에 .was-validated 클래스를 붙이고, 그 아래의 :invalid 만 골라 .invalid-feedback 을 보이게 한다. 클래스 하나 붙이는 것뿐이지만 어쨌든 JS 가 있어야 돌아가는 구조였다.
:has() 가 이 방향을 뒤집는다. .field:has(:invalid) 는 "안에 :invalid 를 가진 .field" 를 고른다. 자식 상태로 부모를 고르는 것이라 감싼 박스·라벨·오류 문구 전부 CSS 만으로 닿는다. 2026년 6월에 Baseline Widely Available 이 되었는데, 실제로는 Safari 15.4(2022)·Chrome 105(2022)·Firefox 121(2023) 부터 지원해서 이미 몇 년 된 기능이다.
다만 :has(:invalid) 로 바꿔 끼우기만 하면 페이지가 열리자마자 폼 전체가 빨갛다. 이 글은 그 문제를 포함해서 Bootstrap 의 .was-validated 가 하던 일을 CSS 로 옮길 때 실제 브라우저가 어떻게 반응하는지 Safari 26.5 와 Chrome 153 에서 재 본 기록이다.
:has(:invalid) 의 문제 — 열자마자 빨갛다
.field:has(:invalid) { border-color: #d33; background: #fff5f5; }
이렇게 쓰면 required 인 빈 칸은 처음부터 :invalid 라서 아무것도 안 했는데 오류 상태로 보인다. 실측에서도 페이지 로드 직후 input.matches(':invalid') 는 true 였다. Bootstrap 이 .was-validated 를 굳이 제출 시점에 붙이는 이유가 이것이다 — :invalid 는 "사용자가 틀렸다" 가 아니라 "지금 값이 규칙에 안 맞는다" 는 뜻이라 타이밍 정보가 없다.
이 타이밍을 채우는 것이 :user-invalid 와 :user-valid 다. "사용자가 손댄 뒤에" 만 걸리는 조건이 붙은 버전이고, :has() 보다 한 달 앞선 2026년 5월에 Widely Available 이 되었다(Firefox 88·Safari 16.5·Chrome 119). :has() 와 :user-invalid 를 합쳐야 .was-validated 대체가 된다.
:user-invalid 가 정확히 언제 켜지나
스펙은 "사용자가 값을 바꾼 뒤" 또는 "제출을 시도한 뒤" 라고 하는데, 실제 키 입력으로 재 보면 시점이 꽤 세밀하다. 이메일 칸 하나를 두고 Safari 와 Chrome 에서 같은 순서로 쳐 봤다.
| 시점 | :invalid |
:user-invalid |
:user-valid |
|---|---|---|---|
| 페이지 로드 (빈 required) | O | — | — |
| "ab" 입력 중 (포커스 유지) | O | — | — |
| Tab 으로 칸을 떠남 | O | O | — |
| 돌아와서 "ab@c.d" 로 고치는 중 (포커스 유지) | — | — | O |
| 손 안 댄 칸이 있는 채로 제출 버튼 | O | O (모든 칸) | — |
두 브라우저가 같았다. 정리하면 세 가지다.
처음 치는 동안은 조용하다. 이메일에 "ab" 까지 쳤을 때 :invalid 는 이미 참이지만 :user-invalid 는 아직 아니다. 타이핑 도중에 빨개지지 않는다. 칸을 떠나는 순간(blur) 켜진다. 검증 UX 에서 흔히 "떠날 때 검사" 라고 부르는 그 시점을 브라우저가 알아서 잡는다.
한 번 켜진 뒤에는 실시간이다. 오류 상태가 된 칸으로 돌아와 고치기 시작하면, 값이 규칙에 맞는 순간 아직 포커스가 있어도 :user-invalid 가 꺼지고 :user-valid 가 켜진다. 빨간 테두리가 타이핑 중에 초록으로 바뀐다. "틀린 뒤엔 고치는 즉시 피드백" 이라는 패턴도 공짜다.
제출 시도는 모든 칸을 한꺼번에 켠다. 손도 안 댄 빈 칸들이 제출 버튼 한 번에 전부 :user-invalid 가 된다. 이게 정확히 Bootstrap 의 .was-validated 가 하던 일이다. 라디오 그룹도 마찬가지로, required 인데 아무것도 안 고른 채 제출하면 라디오 전부가 :user-invalid 가 되어 fieldset:has(:user-invalid) 가 걸린다.
Bootstrap .was-validated 를 CSS 로 옮기기
Bootstrap 마크업은 대개 이 모양이다.
<form class="needs-validation" novalidate>
<div class="mb-3">
<label for="email" class="form-label">이메일</label>
<input type="email" class="form-control" id="email" required>
<div class="invalid-feedback">이메일 형식이 아닙니다.</div>
</div>
<button type="submit">보내기</button>
</form>
<script>
// 제출 시 form 에 .was-validated 를 붙이는 관례 코드
form.addEventListener('submit', e => {
if (!form.checkValidity()) { e.preventDefault(); e.stopPropagation(); }
form.classList.add('was-validated');
});
</script>
스크립트를 지우고 CSS 를 이렇게 바꾼다. 마크업은 그대로다.
/* 감싼 박스 */
.mb-3:has(:user-invalid) { background: #fff5f5; border-radius: 6px; }
/* 라벨 */
.mb-3:has(:user-invalid) > .form-label { color: #b02a37; }
/* 입력칸 자신 — 여기는 :has() 없이 */
.form-control:user-invalid { border-color: #dc3545; }
.form-control:user-valid { border-color: #198754; }
/* 오류 문구: 형제라서 :has() 로 부모를 잡고 내려온다 */
.invalid-feedback { display: none; }
.mb-3:has(:user-invalid) .invalid-feedback { display: block; }
세 번째 블록이 핵심이다. .invalid-feedback 은 입력칸의 형제다. 입력칸 뒤에 있으니 .form-control:user-invalid + .invalid-feedback 처럼 인접 형제 선택자로도 되지만, 마크업이 조금만 달라져도(입력칸 뒤에 아이콘이 하나 끼거나 입력칸이 label 안에 들어가거나) 깨진다. :has() 로 감싼 박스를 잡고 다시 내려오면 박스 안 어디에 있든 상관없다.
novalidate 는 상황에 따라 판단한다. 붙이면 브라우저 기본 말풍선이 안 뜨고 제출은 그대로 나가 버리므로 서버 검증에 기댄다는 뜻이 된다. 떼면 브라우저가 첫 오류 칸에 말풍선을 띄우고 제출을 막는데, 그 순간에도 :user-invalid 는 모든 칸에 켜진다. 실측한 것도 이 경우다. CSS 만으로 갈 거면 떼는 쪽이 자연스럽다.
제출 버튼 — 흐리게는 하되 막지 말 것
form:has(:invalid) button[type="submit"] { opacity: .6; }
폼 안에 하나라도 :invalid 가 있으면 제출 버튼을 흐리게 하는 한 줄이다. 여기는 :user-invalid 가 아니라 :invalid 가 맞다. 로드 직후부터 "아직 다 안 채웠다" 를 보여주는 게 목적이라서다.
대신 pointer-events: none 이나 disabled 로 누르지 못하게 만들면 안 된다. 위에서 본 대로 "손 안 댄 칸 전부를 오류로 켜는" 유일한 트리거가 제출 시도다. 버튼을 막으면 사용자는 뭐가 빠졌는지 영영 못 본다. 흐리게만 하고 누르게 두면 브라우저가 첫 오류 칸으로 포커스를 옮기고 말풍선을 띄운 뒤 나머지 칸도 전부 빨개진다.
CSS 로 안 되는 것 — 그래도 JS 는 한 줄이면 된다
비밀번호 확인처럼 두 칸을 비교하는 규칙은 CSS 로 못 한다. :has() 는 상태를 읽을 뿐 값을 비교하지 않는다. 이런 건 setCustomValidity() 로 브라우저 검증 상태에 결과를 밀어 넣는다.
confirm.addEventListener('input', () => {
confirm.setCustomValidity(confirm.value === password.value ? '' : '불일치');
});
이렇게 하면 confirm 이 :invalid 가 되고(실측 확인), 사용자가 직접 치고 있는 칸이니 위에서 본 것과 같은 시점 규칙으로 :user-invalid 도 따라온다. 표시 쪽 CSS 는 하나도 안 바뀐다. JS 는 "무엇이 틀렸는가" 만 정하고 "어떻게 보여줄 것인가" 는 전부 CSS 에 남는다. 역할이 이렇게 갈리면 검증 규칙이 늘어도 스타일 코드는 그대로다.
정리
:has() 는 입력칸 상태로 감싼 박스·라벨·오류 문구를 고르게 해 주지만, :invalid 와 붙이면 페이지가 열리자마자 빨갛다. :user-invalid 와 붙여야 한다. 실측한 시점은 세 가지다 — 처음 치는 동안은 조용하고, 칸을 떠날 때 켜지고, 한 번 켜진 뒤에는 타이핑 중에도 실시간으로 바뀐다. 제출 시도는 손 안 댄 칸까지 전부 켠다.
이 세 번째가 Bootstrap 의 .was-validated 가 JS 로 하던 일이라, 그 스크립트는 지워도 된다. 남는 JS 는 두 칸 비교 같은 규칙에 setCustomValidity() 한 줄이고, 그마저도 표시 쪽 CSS 는 건드리지 않는다. 제출 버튼은 흐리게만 하고 막지 말 것 — 그 버튼이 "전부 켜는" 스위치다.