Зачем нужно
Валидация формы в реальном времени даёт пользователю мгновенную обратную связь при заполнении полей. Вместо сообщения об ошибке после нажатия "Отправить" пользователь видит подсказки прямо во время ввода. Это снижает количество ошибок и улучшает UX.
Где используется
- Формы регистрации и авторизации
- Формы оформления заказа
- Профиль пользователя (редактирование)
- Контактные формы
- Любые формы, где важен корректный ввод
Стратегии валидации
| Стратегия |
Когда срабатывает |
Когда применять |
На blur |
При уходе с поля |
Основная стратегия -- не мешает вводу |
На input |
При каждом символе |
Для мгновенного отклика (пароль) |
На submit |
При отправке формы |
Финальная проверка |
Debounced input |
Через N мс после ввода |
API-проверки (email, username) |
Рекомендуемый подход: валидация на blur + повторная валидация на input после первой ошибки.
HTML с Constraint Validation API
HTML
<form class="form" id="registrationForm" novalidate>
<div class="form-field">
<label for="username" class="form-field__label">
Имя пользователя <span class="required">*</span>
</label>
<input type="text" id="username" name="username" class="form-field__input"
required minlength="3" maxlength="20"
pattern="^[a-zA-Z0-9_]+$"
autocomplete="username" />
<span class="form-field__error" aria-live="polite"></span>
<span class="form-field__hint">3-20 символов, буквы, цифры и _</span>
</div>
<div class="form-field">
<label for="email" class="form-field__label">
Email <span class="required">*</span>
</label>
<input type="email" id="email" name="email" class="form-field__input"
required autocomplete="email" />
<span class="form-field__error" aria-live="polite"></span>
</div>
<div class="form-field">
<label for="password" class="form-field__label">
Пароль <span class="required">*</span>
</label>
<input type="password" id="password" name="password" class="form-field__input"
required minlength="8"
autocomplete="new-password" />
<span class="form-field__error" aria-live="polite"></span>
<div class="password-strength" id="passwordStrength">
<div class="password-strength__bar"></div>
<span class="password-strength__text"></span>
</div>
</div>
<div class="form-field">
<label for="confirmPassword" class="form-field__label">
Подтверждение пароля <span class="required">*</span>
</label>
<input type="password" id="confirmPassword" name="confirmPassword"
class="form-field__input" required autocomplete="new-password" />
<span class="form-field__error" aria-live="polite"></span>
</div>
<button type="submit" class="btn btn--primary" id="submitBtn">
Зарегистрироваться
</button>
</form>
CSS
.form-field {
margin-bottom: 20px;
}
.form-field__label {
display: block;
margin-bottom: 6px;
font-weight: 500;
font-size: 14px;
}
.required {
color: #e53e3e;
}
.form-field__input {
width: 100%;
padding: 10px 12px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.2s, box-shadow 0.2s;
outline: none;
}
.form-field__input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
.form-field__input.valid {
border-color: #22c55e;
}
.form-field__input.valid:focus {
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15);
}
.form-field__input.invalid {
border-color: #e53e3e;
}
.form-field__input.invalid:focus {
box-shadow: 0 0 0 3px rgba(229, 62, 62, 0.15);
}
.form-field__error {
display: block;
margin-top: 4px;
font-size: 13px;
color: #e53e3e;
min-height: 20px;
}
.form-field__hint {
display: block;
margin-top: 4px;
font-size: 12px;
color: #999;
}
.password-strength {
margin-top: 8px;
}
.password-strength__bar {
height: 4px;
border-radius: 2px;
background: #e0e0e0;
transition: width 0.3s, background 0.3s;
}
.password-strength__bar[data-strength="weak"] {
width: 33%;
background: #e53e3e;
}
.password-strength__bar[data-strength="medium"] {
width: 66%;
background: #eab308;
}
.password-strength__bar[data-strength="strong"] {
width: 100%;
background: #22c55e;
}
.password-strength__text {
font-size: 12px;
margin-top: 4px;
}
JavaScript
class FormValidator {
constructor(form) {
this.form = form;
this.fields = new Map();
this.touched = new Set();
this.init;
}
init {
this.form.querySelectorAll('[required], [pattern], [minlength]').forEach((input) => {
this.fields.set(input.name, {
input,
errorEl: input.parentElement.querySelector('.form-field__error'),
});
input.addEventListener('blur', () => {
this.touched.add(input.name);
this.validateField(input);
});
input.addEventListener('input', () => {
if (this.touched.has(input.name)) {
this.validateField(input);
}
});
});
this.setupPasswordStrength;
this.setupPasswordMatch;
this.form.addEventListener('submit', (e) => this.onSubmit(e));
}
validateField(input) {
const field = this.fields.get(input.name);
if (!field) return true;
input.setCustomValidity('');
if (input.name === 'confirmPassword') {
this.checkPasswordMatch(input);
}
const isValid = input.checkValidity;
if (isValid) {
input.classList.remove('invalid');
input.classList.add('valid');
field.errorEl.textContent = '';
} else {
input.classList.remove('valid');
input.classList.add('invalid');
field.errorEl.textContent = this.getErrorMessage(input);
}
return isValid;
}
getErrorMessage(input) {
const validity = input.validity;
if (validity.valueMissing) {
return 'Это поле обязательно для заполнения';
}
if (validity.typeMismatch) {
if (input.type === 'email') return 'Введите корректный email';
return 'Некорректный формат';
}
if (validity.tooShort) {
return `Минимум ${input.minLength} символов (сейчас ${input.value.length})`;
}
if (validity.tooLong) {
return `Максимум ${input.maxLength} символов`;
}
if (validity.patternMismatch) {
if (input.name === 'username') {
return 'Только буквы, цифры и _';
}
return 'Некорректный формат';
}
if (validity.customError) {
return input.validationMessage;
}
return 'Некорректное значение';
}
setupPasswordStrength {
const password = this.form.querySelector('#password');
const strengthBar = this.form.querySelector('.password-strength__bar');
const strengthText = this.form.querySelector('.password-strength__text');
if (!password || !strengthBar) return;
password.addEventListener('input', () => {
const strength = this.calculateStrength(password.value);
strengthBar.dataset.strength = strength.level;
strengthText.textContent = strength.text();
});
}
calculateStrength(password) {
let score = 0;
if (password.length >= 8) score++;
if (password.length >= 12) score++;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;
if (/\d/.test(password)) score++;
if (/[^a-zA-Z0-9]/.test(password)) score++;
if (score <= 2) return { level: 'weak', text: 'Слабый пароль' };
if (score <= 3) return { level: 'medium', text: 'Средний пароль' };
return { level: 'strong', text: 'Надёжный пароль' };
}
setupPasswordMatch {
const confirm = this.form.querySelector('#confirmPassword');
if (!confirm) return;
confirm.addEventListener('input', () => {
if (this.touched.has('confirmPassword')) {
this.checkPasswordMatch(confirm);
this.validateField(confirm);
}
});
}
checkPasswordMatch(confirmInput) {
const password = this.form.querySelector('#password');
if (password && confirmInput.value !== password.value) {
confirmInput.setCustomValidity('Пароли не совпадают');
} else {
confirmInput.setCustomValidity('');
}
}
onSubmit(e) {
e.preventDefault();
this.fields.forEach((_, name) => this.touched.add(name));
let isValid = true;
this.fields.forEach(({ input }) => {
if (!this.validateField(input)) {
isValid = false;
}
});
if (isValid) {
const data = new FormData(this.form);
console.log('Форма валидна, отправляем:', Object.fromEntries(data));
} else {
const firstInvalid = this.form.querySelector('.invalid');
firstInvalid?.focus();
}
}
}
new FormValidator(document.getElementById('registrationForm'));
Debounced валидация (для API-проверок)
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout( => fn(...args), delay);
};
}
const checkUsername = debounce(async (input, errorEl) => {
if (input.value.length < 3) return;
try {
const res = await fetch(`/api/check-username?name=${input.value}`);
const { available } = await res.json();
if (!available) {
input.setCustomValidity('Это имя уже занято');
input.classList.add('invalid');
errorEl.textContent = 'Это имя уже занято';
} else {
input.setCustomValidity('');
}
} catch (err) {
input.setCustomValidity('');
}
}, 500);
Нативные CSS-псевдоклассы
input:valid {
border-color: green;
}
input:invalid {
border-color: red;
}
input:not(:placeholder-shown):invalid {
border-color: red;
}
input:user-invalid {
border-color: red;
}
Constraint Validation API
| Метод / свойство |
Описание |
input.validity |
Объект с флагами валидности |
input.checkValidity |
Проверяет валидность, возвращает boolean |
input.reportValidity |
Проверяет и показывает нативный tooltip |
input.setCustomValidity(msg) |
Устанавливает кастомную ошибку |
input.validationMessage |
Текст текущей ошибки |
validity.valueMissing |
Поле required пустое |
validity.typeMismatch |
Не совпадает с type (email, url) |
validity.patternMismatch |
Не совпадает с pattern |
validity.tooShort / tooLong |
Нарушение min/maxlength |
Частые ошибки
| Ошибка |
Проблема |
Решение |
| Ошибка при каждом символе |
Раздражает пользователя |
Валидация на blur, потом на input |
Нет aria-live на ошибке |
Скринридер не озвучит ошибку |
aria-live="polite" |
Забыли novalidate |
Нативные тултипы мешают кастомным |
Добавь novalidate к form |
| Нет фокуса на первое ошибочное поле |
Пользователь не видит ошибку |
firstInvalid.focus() |
| API-валидация без debounce |
Слишком много запросов |
Debounce 300-500ms |
Связанные темы
Ресурсы