Доступные формы — нативная валидация vs кастом
Половина форм в рунете ломают скринридер, перехватывают Tab и показывают ошибку красным текстом без `aria-describedby`. Разбираем, что даёт нативная HTML-валидация бесплатно, где её недостаточно, и как сделать кастомную без потери доступности.
Форма — самое опасное место для accessibility. Кнопка может быть некликабельной всю жизнь, никто не заметит. А форму на сайте каждый день заполняют люди — включая тех, кто пользуется скринридером, голосовым вводом, клавиатурой без мыши. И каждое сломанное поле — это потерянная заявка.
По данным WebAIM 2025, 86% форм на топ-миллион сайтов имеют хотя бы одну ошибку accessibility. У русскоязычного сегмента доля выше — потому что половина команд верит, что accessibility «нужен только для госсайтов».
Что нативный HTML даёт бесплатно
Браузер сам делает много полезного, если вы не мешаете:
<label for="...">связывает подпись и поле — скринридер читает «Имя, обязательно, edit text».requiredблокирует сабмит и показывает сообщение от браузера.type="email",type="tel",type="url"— валидация формата и нужная клавиатура на мобиле.pattern="..."+title="..."— кастомный regex и сообщение для скринридера.autocomplete="name|email|tel"— менеджер паролей и автозаполнение.inputmode="numeric|decimal"— цифровая клавиатура без смены типа.
Минимальная доступная форма заявки
<form action="/lead" method="post" novalidate>
<div>
<label for="name">Имя</label>
<input id="name" name="name" type="text" required
autocomplete="name" aria-describedby="name-hint">
<p id="name-hint">Как к вам обращаться</p>
</div>
<div>
<label for="phone">Телефон</label>
<input id="phone" name="phone" type="tel" required
autocomplete="tel" inputmode="tel"
pattern="\+?[0-9 ()\-]{7,}"
title="Минимум 7 цифр, можно +, пробелы, скобки">
</div>
<button type="submit">Отправить</button>
</form>
Скринридер читает каждое поле с подписью, типом, статусом «обязательно» и подсказкой из aria-describedby. На сабмите при ошибке фокус прыгает на первое невалидное поле — браузер сделает сам, если не отключить.
Почему ставят novalidate
Нативное сообщение «Please fill out this field» — на языке браузера, не сайта. У русского пользователя оно может быть на английском. Это раздражает дизайнера и часто становится поводом отрубить нативку через novalidate и нарисовать всё самим.
Это работает только если дальше реализованы все 6 пунктов из списка ниже. Иначе форма становится недоступной.
Шесть требований к кастомной валидации
- Каждое поле имеет видимый label. Placeholder — не замена. Он исчезает при вводе и не читается скринридером как подпись.
- Ошибка связана с полем через
aria-describedby. Иначе скринридер прочитает только название поля, без сообщения. - Поле в ошибке имеет
aria-invalid="true". Скринридер скажет «invalid». - Сообщение об ошибке появляется в
role="alert"илиaria-live="polite". Иначе скринридер не уведомит о новой ошибке после сабмита. - Фокус после сабмита прыгает на первое невалидное поле. Иначе пользователь клавиатуры теряется.
- Цвет — не единственный сигнал ошибки. Дальтоники, плохое освещение, тёмная тема — везде один красный border читается как «обычный». Нужна иконка, текст, рамка с pattern.
Минимальный кастом-вариант
<div>
<label for="email">Email</label>
<input id="email" name="email" type="email" required
aria-describedby="email-error" aria-invalid="false">
<p id="email-error" role="alert"></p>
</div>
<script>
const input = document.querySelector('#email');
const error = document.querySelector('#email-error');
input.addEventListener('invalid', e => {
e.preventDefault();
input.setAttribute('aria-invalid', 'true');
error.textContent = input.validationMessage; // нативное сообщение
});
input.addEventListener('input', () => {
if (input.checkValidity()) {
input.setAttribute('aria-invalid', 'false');
error.textContent = '';
}
});
</script>
Здесь мы оставили нативную валидацию (constraint validation API), но рендерим сообщение сами в нужном месте, с правильным aria-describedby и role="alert".
Когда без JS-валидации не обойтись
Нативка не покрывает:
- Серверную проверку (email уже занят, промокод недействителен).
- Кросс-полевую логику (пароль = подтверждение, дата выезда после даты заезда).
- Условные обязательные поля (юрлицо → ИНН обязателен, физлицо — нет).
- Кастомные сообщения на языке сайта.
- Маски ввода (телефон, дата, карта).
Маски — главный убийца accessibility
Половина библиотек масок (inputmask, imask, react-input-mask) ломают:
- Вставку из буфера — пользователь копирует «+7 (999) 123-45-67», получает кашу.
- Голосовой ввод — Android Voice Input не понимает наполовину заполненную маску.
- Скринридер — VoiceOver читает каждый символ маски при движении курсора.
- Менеджер паролей — не понимает, куда вставить телефон.
Лучший паттерн в 2026 — без маски, но с inputmode="tel" и нормализацией на сервере. Пользователь пишет как удобно (с пробелами, скобками, +7 или 8), сервер приводит к +7XXXXXXXXXX. Это не ломает accessibility и не раздражает на мобиле.
Чекбоксы и радиокнопки
Группы обязательно оборачивать в <fieldset> с <legend>. Иначе скринридер прочитает «checkbox, not checked» без контекста, что выбираем.
<fieldset>
<legend>Способ связи</legend>
<label><input type="radio" name="contact" value="call"> Звонок</label>
<label><input type="radio" name="contact" value="tg"> Telegram</label>
<label><input type="radio" name="contact" value="email"> Email</label>
</fieldset>
Согласие на обработку данных
Чекбокс «Согласен на обработку персональных данных» — отдельная боль. По 152-ФЗ согласие должно быть осознанным. В UX это значит:
- Чекбокс не должен быть по умолчанию отмечен.
- Текст согласия — кликабельная ссылка на политику в новом табе (
target="_blank" rel="noopener"). - Поле обязательное — не отправляется без галки.
- В описании ошибки конкретно: «Подтвердите согласие на обработку данных», не «Заполните обязательные поля».
Live region для статуса сабмита
После нажатия «Отправить» — что произошло, скринридер не узнает без aria-live.
<p id="form-status" role="status" aria-live="polite"></p>
<script>
form.addEventListener('submit', async e => {
e.preventDefault();
status.textContent = 'Отправляем заявку...';
const res = await fetch(form.action, { method:'POST', body: new FormData(form) });
status.textContent = res.ok
? 'Заявка отправлена. Свяжемся в течение часа.'
: 'Ошибка отправки. Попробуйте ещё раз или напишите в Telegram.';
});
</script>
Чеклист на час
- У каждого
<input>есть<label>со связью черезfor/id. - Placeholder не заменяет label.
- Все обязательные поля помечены
required. - Сообщение об ошибке связано через
aria-describedby. - При ошибке выставляется
aria-invalid="true". - Контейнер ошибки имеет
role="alert"илиaria-live="polite". - Группы радио/чекбоксов в
<fieldset>с<legend>. - Цвет — не единственный сигнал.
- Фокус после сабмита уходит на первое невалидное поле.
- Тестируйте Tab-навигацией без мыши — все элементы достижимы.
- Тестируйте VoiceOver (Cmd+F5 на macOS) или NVDA — поле читается осмысленно.
Вывод
Нативный HTML даёт большую часть accessibility бесплатно — нужно не мешать. Кастомная валидация оправдана только если соблюдены 6 требований по ARIA-связкам и фокусу. На лендинге студии накатывать что-то сложнее формы из 3 полей с нативной валидацией + лёгким JS-перехватом сообщений — обычно overkill.