:user-invalid и :user-valid: отложенная валидация без JS и без мигания ошибокКлассическая боль любой формы:
input:invalid срабатывает сразу при рендере. Пользователь ещё ничего не вводил, а поле уже красное. Дальше - три костыля на выбор: :not(:placeholder-shown), класс .touched из JS или novalidate + ручная валидация.Селекторы уровня 4 закрывают это нативно.
Как это работает
:user-invalid матчится, только если элемент одновременно:1. кандидат на constraint validation и сейчас невалиден,
2. был «изменён пользователем» (
user interacted).Второй пункт - ключевой. По HTML-спеке флаг выставляется, когда пользователь изменил значение или попытался отправить форму. То есть до первого реального действия с полем оно остаётся нейтральным, а после сабмита все проблемные поля подсвечиваются разом - именно тот UX, к которому мы руками приходили через JS.
:user-valid - зеркало: валидно и было взаимодействие. Полезно, чтобы не красить зелёным пустые нетронутые поля.Код
/* Плохо: красное с первого кадра рендера */
input:invalid { border-color: #e5484d; }
/* Ок: ошибка - только после взаимодействия */
input:user-invalid { border-color: #e5484d; }
input:user-valid { border-color: #30a46c; }
/* сообщение без JS: заранее в разметке, показ через CSS */
.field .msg { visibility: hidden; }
.field:has(:user-invalid) .msg { visibility: visible; }
.field:has(:user-invalid) .msg::before { content: attr(data-msg); }
<div class="field">
<input type="email" required>
<span class="msg" data-msg="Похоже, в адресе опечатка"></span>
</div>
Работает с
input, select, textarea, любым типом и любым ограничением: required, pattern, minlength, type="email". Реагирует и на «ввёл - стёр», и на попытку сабмита.Подводные камни
* Поддержка - Baseline с конца 2023 (Chrome 119+, Firefox 88+, Safari 16.5+). Для более старых - тот же селектор в
:where()-фолбэке или @supports selector(:user-invalid).* Blur сам по себе флаг не ставит. Если нужно «валидировать по потере фокуса, даже если значение не менялось» - это по-прежнему JS. Но в 95% форм такое поведение и не нужно.
*
required никуда не делся. :user-invalid меняет момент показа, а не правила валидации.* A11y не бесплатно. Псевдоклассы не выставляют
aria-invalid - скринридер про ошибку не узнает. Либо дублируйте состояние атрибутом, либо полагайтесь на нативную браузерную валидацию при сабмите.* Не стилизуйте
:invalid вообще в формах с ранним показом - иначе правила конфликтуют и «мигание» возвращается.Итого
:user-invalid убирает целый слой JS-состояния: флаг «тронуто/не тронуто» больше не нужно тащить в классы и стейт-менеджер. Разметка описывает намерение, CSS - момент показа. Классический кейс, где платформа догнала практику.