폼 검증 스타일을 :invalid로 붙여본 적 있으면 이 짜증을 안다. 이메일 입력창에 required를 걸고 input:invalid { border-color: red; }를 주면, 사용자가 아무것도 입력하지 않은 상태 — 즉 페이지가 막 뜬 순간부터 모든 칸이 빨갛게 물든다. 아직 입력할 기회도 안 줬는데 "너 틀렸어"라고 소리치는 셈이다.
그래서 다들 JS로 처리했다. blur 이벤트를 걸고, "touched" 상태를 따로 관리하고, 클래스를 토글하고... 폼 하나에 상태 관리 코드가 붙는다.
:user-invalid이 하는 일
:user-invalid는 "사용자가 실제로 상호작용한 뒤에" 값이 유효하지 않을 때만 매칭된다. 정확히는 사용자가 값을 입력하거나 폼을 제출하려 시도한 다음부터 판정이 켜진다. 반대로 :user-valid는 상호작용 후 유효할 때 매칭된다.
:invalid와 규칙 자체는 같다. 차이는 타이밍이다. :invalid는 처음부터 판정하고, :user-invalid는 사용자가 만진 뒤에 판정한다. 우리가 JS로 힘겹게 흉내 내던 "touched" 로직이 브라우저에 기본 내장된 것이다.
써보기
<label>
이메일
<input type="email" required />
<span class="hint">올바른 이메일 형식이 아닙니다</span>
</label>
input:user-invalid {
border-color: #e5484d;
}
input:user-valid {
border-color: #30a46c;
}
/* 에러 메시지는 평소 숨기고, 잘못 입력했을 때만 노출 */
.hint {
display: none;
color: #e5484d;
font-size: 0.85rem;
}
input:user-invalid + .hint {
display: block;
}
이게 전부다. 페이지 로드 직후엔 아무 표시도 없다가, 사용자가 이메일 칸에 abc까지만 치고 다른 곳으로 넘어가면 그때 빨간 테두리와 힌트가 뜬다. 제대로 된 이메일을 넣으면 초록으로 바뀐다. JS는 한 줄도 없다.
:has()랑 붙이면 더 깔끔하다
에러 메시지 대신 label 전체 톤을 바꾸고 싶다면 부모를 선택해야 한다. :has()와 조합하면 된다.
label:has(input:user-invalid) {
color: #e5484d;
}
한 가지 주의점
:user-invalid는 요소가 포커스를 잃거나(blur) 폼 제출을 시도하기 전까지는 켜지지 않는다. 그래서 "입력하는 도중에는 조용하다가, 다 치고 칸을 벗어나면 검사"라는, 사실 우리가 원하던 바로 그 UX가 공짜로 나온다. 반대로 실시간 검증이 꼭 필요하면 이 선택자만으로는 부족하니 그땐 JS를 써야 한다.
지원 범위도 넉넉하다. 2023년 후반부터 주요 브라우저에 다 들어왔고 지금은 Baseline이다. 폼 검증 UX 하나 만들자고 상태 훅 붙이던 시절은 지났다 — 선택자 두 개면 된다.