Форма входа и регистрации — одно из самых узких мест в любом продукте: через неё проходит каждый новый пользователь, и каждая мелкая ошибка верстки превращается в потерянного клиента. Менеджер паролей не подставил email, на телефоне открылась раскладка с буквами вместо клавиш с собачкой, после клика на «Войти» форма отправилась на сервер дважды — и человек закрывает вкладку.
Хорошая новость в том, что почти все эти проблемы лечатся правильными HTML-атрибутами и парой строк CSS — никакой особой магии. Соберём набор правил, по которым форма перестаёт раздражать всех участников процесса: пользователя, менеджер паролей, экранный читалку и автотесты. Заодно покажем нюансы, на которые легко наступить, даже когда вёрстка форм за плечами уже многие годы.
Помогаем браузеру угадать, что это за поле
Браузер сам не знает, ваше поле для email или для логина по никнейму. Менеджер паролей тоже не знает, эта форма для входа или регистрации. Это всё мы сообщаем явно — через атрибуты type, autocomplete и inputmode.
Начнём с базы. У текстового поля для адреса электронной почты ставим type="email": это включает экранную клавиатуру с символом @ и точкой на мобильных, базовую валидацию формата и подсказки автозаполнения.
<label>
Электронная почта:
<input type="email" autocomplete="username">
</label>
Дальше — самое полезное: атрибут autocomplete. Менеджер паролей по нему понимает, какой формат данных вы у пользователя просите. Для логина и регистрации актуальны три значения:
- autocomplete="username" — для поля email или ника в обеих формах. Да, имя username сбивает с толку, когда там почта, но так задумано в спецификации: оно показывает менеджеру паролей, что это поле идентификатора пользователя в его записи.
- autocomplete="current-password" — для пароля на форме входа. Менеджер подставит сохранённый.
- autocomplete="new-password" — для обоих полей пароля на форме регистрации (основное и «повторите»). Это сигнал «предложи сгенерировать надёжный» и «сохрани новый», а не «подставь существующий».
<!-- Форма входа -->
<form>
<label>Email: <input type="email" autocomplete="username"></label>
<label>Пароль: <input type="password" autocomplete="current-password"></label>
<button>Войти</button>
</form>
<!-- Форма регистрации -->
<form>
<label>Email: <input type="email" autocomplete="username"></label>
<label>Новый пароль: <input type="password" autocomplete="new-password"></label>
<label>Повторите пароль: <input type="password" autocomplete="new-password"></label>
<button>Создать аккаунт</button>
</form>
Отдельно стоит упомянуть значение, которое сегодня встречается на каждом втором сайте — autocomplete="one-time-code". Это поле для одноразового кода, который приходит в SMS или из приложения-аутентификатора. С этим атрибутом iOS Safari и Chrome для Android считывают код из системного уведомления и предлагают вставить его в поле одним тапом — без переключения между приложениями.
<label>
Код из SMS:
<input type="text" inputmode="numeric" autocomplete="one-time-code">
</label>
Здесь же видна логика атрибута inputmode: если у поля по смыслу нужна не та клавиатура, что подразумевает type, мы её переопределяем. Для шестизначного кода — цифровая (numeric), для поля поиска — search, для телефона — tel. Это не влияет на десктоп, зато на мобильном клавиатура открывается сразу удобная.
Семантика, а не «кликабельный <div>»
В каждой второй форме встречается одно и то же: рядом с обычными полями ввода живёт что-нибудь вроде <div onclick="...">Войти</div>. Внешне нормально, по факту — ломается всё:
- фокус с клавиатуры на такой элемент не попадает (нет tabindex);
- Enter и Space не активируют;
- скринридер прочитает текст, но не назовёт роль «кнопка» — пользователь не поймёт, что это вообще действие;
- в режиме навигации по формам элемент не обнаружится.
Лечится возвратом к семантике. На кнопку, которая что-то делает (отправить форму, открыть пароль, переключить состояние) — <button>. На текст, который ведёт на другую страницу («Забыли пароль?», «Регистрация») — <a> с настоящим href:
<button>Войти</button>
Забыли пароль? <a href="/restore">Восстановить</a>.
Если кнопка только с иконкой и без текста — обязательно добавляем aria-label, иначе скринридер прочитает её как «кнопка» без пояснений:
<button type="button" aria-label="Показать пароль">
<svg ...></svg>
</button>
И сама форма должна быть формой. Обёртка в <form> даёт сразу три вещи: сабмит по Enter из любого поля, режим навигации по формам у скринридеров и базовое нативное поведение для отправки.
<form>
<label>Email: <input type="email" autocomplete="username"></label>
<label>Пароль: <input type="password" autocomplete="current-password"></label>
<button>Войти</button>
</form>
Отдельный, но логически близкий приём — чекбокс внутри <label>. Когда галочка «Запомнить меня» и её текст находятся в одном <label>, кликом по тексту тоже меняется состояние галочки. Это увеличивает зону нажатия в десять раз — критично на тачскрине, где попасть пальцем в 16-пиксельный квадратик неудобно.
<label>
<input type="checkbox"> Согласен с политикой обработки данных
</label>
Подсветить всю строку при наведении — одной строчкой CSS:
label:has(input[type="checkbox"]):hover {
background: rgba(0 0 0 / 6%);
}
Подпись поля — это <label>, а не плейсхолдер
Лень написать <label> — и в шаблон отправляется <input placeholder="Email">. Внешне почти то же самое: серая надпись внутри поля, поле как поле. Только пользователь начинает вводить — надпись исчезает, и человек уже не помнит, что он заполняет. Это особенно неприятно в длинных формах, куда возвращаешься через минуту, чтобы перепроверить.
Дополнительно: серый плейсхолдер обычно низкоконтрастный (помогает не путать его с реальным значением) — но это же делает его нечитаемым на солнце, на проекторе, у людей со слабым зрением. Скринридеры обращаются с placeholder непредсказуемо: одни читают как подпись, другие игнорируют. Гугл-переводчик значения атрибутов обычно не переводит — и в локализованной форме плейсхолдеры остаются на исходном языке.
Правильный шаблон, от которого никогда не надо отклоняться:
<label>
Электронная почта
<input type="email" autocomplete="username">
</label>
Если по дизайну надпись точно нужна снаружи поля, а не оборачивать — используем атрибут for:
<label for="email">Электронная почта</label>
<input id="email" type="email" autocomplete="username">
Плейсхолдер при этом не запрещён в принципе — он отлично работает как пример формата ввода («+7 (___) ___-__-__» для телефона), но именно как дополнение к <label>, а не замена. Более детально мы разбирали этот аргумент в отдельной заметке про placeholder.
Видимый фокус для клавиатурной навигации
Самый частый запрос дизайнера: «убери, пожалуйста, эту синюю обводку, она портит дизайн». Старый ответ — outline: none. Сейчас так делать нельзя: индикатор фокуса — единственный визуальный сигнал для тех, кто навигирует Tab-ом. Уберёте его — и человек просто не понимает, на каком поле он сейчас.
Правильный приём — псевдокласс :focus-visible. Он применяет стиль только когда браузер сам решает, что подсказка нужна: при навигации с клавиатуры обводка появляется, при клике мышью — нет. Никаких больше «кликнул на кнопку и она светится синим».
:focus-visible {
outline: 2px solid #4f46e5;
outline-offset: 2px;
}
Свойство outline-offset — маленькая, но важная деталь: оно отодвигает обводку от границы элемента, чтобы у скруглённых углов и собственных рамок не получалось грязного наложения. В чем разница между :focus и :focus-visible — в обзоре CSS-настроек для кнопки.
Один нюанс, на который легко наступить и не понять, почему не работает. У полей <input> и <textarea> псевдокласс :focus-visible срабатывает всегда при получении фокуса — и от Tab, и от клика мышью. Так задумано в спецификации: в текстовое поле пользователь всё равно будет печатать с клавиатуры, значит подсказка ему нужна. Разница между :focus и :focus-visible видна только на нетекстовых интерактивных элементах: кнопках, ссылках, чекбоксах, радио, селектах. Поэтому для формы это значит одно: всё равно используем :focus-visible, просто понимаем, что у инпута он сработает так же, как старый :focus.
Ошибки: маркировка и тайминг
Когда поле заполнено неправильно, об этом нужно сообщить двум аудиториям сразу: и зрячему пользователю (рамкой, цветом, текстом ошибки), и скринридеру (атрибутами, которые он озвучит). На CSS-класс .invalid рассчитывать нельзя — скринридер его не читает.
Минимальный набор атрибутов: required для нативной валидации, aria-invalid="true" для метки «здесь ошибка» и aria-errormessage со ссылкой на id с текстом ошибки.
<label>
Электронная почта
<input type="email"
autocomplete="username"
required
aria-invalid="true"
aria-errormessage="email-error">
</label>
<span id="email-error">Введите корректный email</span>
Здесь есть нюанс, без которого пример лучше не вкатывать в прод. Атрибут aria-errormessage поддерживается экранными читалками неровно: JAWS и NVDA до сих пор обращаются с ним частично, особенно в Firefox. Поэтому в реальных проектах его обычно дублируют атрибутом aria-describedby, который поддерживается везде:
<input type="email"
autocomplete="username"
required
aria-invalid="true"
aria-errormessage="email-error"
aria-describedby="email-error">
<span id="email-error">Введите корректный email</span>
Когда поле станет валидным — не забываем переключить aria-invalid на "false" и убрать сообщение из DOM (или скрыть его атрибутом hidden).
Второй вопрос — когда именно показать пользователю эту ошибку. Стандартная ловушка: повесить валидацию на событие input и кричать «email невалидный», как только человек ввёл первую букву. Раздражает, ломает поток ввода, мешает.
Рабочий компромисс выглядит так:
- на событие input разрешаем только «похвалу» — если поле стало валидным, тут же подсветим зелёным;
- на событие change (срабатывает при потере фокуса) показываем ошибку, если она есть.
input.addEventListener('input', () => {
if (isValid(input)) markValid(input);
});
input.addEventListener('change', () => {
if (isValid(input)) {
markValid(input);
} else {
markInvalid(input);
}
});
Логика проста и универсальна: пока человек ещё печатает, мы его не одёргиваем, но как только он отвлёкся — даём обратную связь. Сабмит формы, разумеется, прогоняет валидацию по всем полям сразу.
Отправляемся один раз, а не два
Классический баг — пользователь нервно тыкает кнопку «Войти», форма уходит на сервер дважды, на бэке всплывает либо два письма с подтверждением, либо ошибка дублирующейся записи. Лечится дизейблом кнопки на момент отправки.
form.addEventListener('submit', () => {
submitButton.disabled = true;
// Подстраховка для Firefox: иначе при перезагрузке страницы
// disabled застрянет и кнопка останется недоступной.
submitButton.autocomplete = 'off';
// Если перезагрузка через 2 секунды не случилась — снимаем дизейбл.
// Для AJAX-варианта лучше делать это в finally (см. следующий раздел).
setTimeout(() => {
submitButton.disabled = false;
}, 2000);
});
Нюанс с Firefox подтверждается давно известным багом на StackOverflow. Браузер запоминает динамически выставленный disabled на форм-элементах и восстанавливает его при перезагрузке страницы. Если не сбросить значение autocomplete на самом элементе кнопки, после случайного reload юзер увидит мёртвую кнопку. Звучит экзотически, но ловится регулярно.
AJAX: сеть всегда подведёт
Большинство современных форм входа отправляют данные через fetch, а не классическим page reload. Это удобнее (страница не моргает, ошибки выводим красиво) и одновременно опаснее: всё, что раньше браузер брал на себя — индикатор загрузки в табе, сообщение «нет сети», прерывание двойной отправки — теперь наше.
Стандартная обёртка выглядит так:
form.addEventListener('submit', async (event) => {
event.preventDefault();
showLoader();
submitButton.disabled = true;
try {
const response = await fetch('/api/login', {
method: 'POST',
body: new FormData(form),
});
if (!response.ok) {
throw new Error('Не получилось войти. Проверьте логин и пароль.');
}
location.href = '/dashboard';
} catch (error) {
showError(error.message);
} finally {
hideLoader();
submitButton.disabled = false;
}
});
Что здесь принципиально:
- лоадер видим сразу — пользователь понимает, что запрос пошёл, и не давит на кнопку снова;
- обработка ошибок не «на потом»: try/catch отрабатывает и сетевые сбои (телефон выехал из метро), и серверные (5xx, неправильный пароль);
- finally гарантированно снимает дизейбл и прячет лоадер — иначе при сетевой ошибке форма залипнет в состоянии «ждём» навсегда.
Сообщение об ошибке должно быть человеческим. «TypeError: Failed to fetch» в интерфейсе — антипаттерн; пользователь увидит «Нет сети, проверьте подключение», а в консоль для разработчика уже летит стек ошибки.
Чеклист
Краткая сводка, которую можно прогнать по любой существующей форме за пять минут:
- у каждого поля есть свой <label> (обёрткой или через for);
- email — это type="email", поле с одноразовым кодом — inputmode="numeric" и autocomplete="one-time-code";
- autocomplete расставлен правильно: username, current-password, new-password;
- форма обёрнута в <form>, кнопка отправки — <button>, ссылки на другие страницы — <a href>;
- иконочные кнопки имеют aria-label;
- чекбоксы лежат внутри <label> вместе с подписью;
- есть стиль :focus-visible для всех интерактивных элементов формы;
- ошибки помечены aria-invalid и aria-describedby (плюс aria-errormessage, если хочется будущего);
- валидация: «похвала» на input, «ошибка» на change;
- сабмит блокирует кнопку, а после AJAX-ответа разблокирует её в finally.
Список выглядит длинным, но почти каждый пункт — один атрибут или одна строка CSS/JS. Один раз настроенная по этим правилам форма перестаёт быть источником багов и одинаково хорошо ведёт себя на десктопе, мобильном и в скринридере. Те же принципы переносятся почти без изменений на любые другие формы — от подписки на рассылку до многошаговых оформлений заказа. Дополнительные приёмы по верстке полей мы собирали в обзоре улучшений HTML-форм.
Комментарии (0)