Доступность вспоминают в двух ситуациях: когда пришло требование от заказчика и когда интерфейс уже готов. В обоих случаях это переделка, и оценивают её неделями.
Большую часть частых ошибок можно поймать почти бесплатно, если проверять несколько вещей при вёрстке каждого экрана. Это не замена аудиту и тестированию с экранными дикторами, а короткий базовый список, который не даёт накопить очевидные проблемы.
Иерархия заголовков
Один основной h1 на страницу и логичная вложенность следующих уровней — полезная договорённость, хотя WCAG не требует буквально одного h1 и не объявляет любой пропуск уровня ошибкой. Проблема начинается, когда уровень выбирают по размеру шрифта, а не по структуре.
Типичная ситуация: в карточке нужен мелкий заголовок, ставят h4, потому что он мельче. На странице получается h1, потом сразу h4. Для человека с экранным диктором это выглядит как пропущенный кусок структуры.
Правильно: уровень выбирается по вложенности, размер задаётся классом.
<h2 class="text-sm font-semibold uppercase tracking-wide">Участники</h2>Проверяется за десять секунд: в консоли браузера вывести все заголовки страницы и посмотреть, нет ли скачков.
console.table([...document.querySelectorAll('h1,h2,h3,h4,h5,h6')]
.map(h => ({ level: h.tagName, text: h.textContent.trim().slice(0, 60) })));Метки полей
Плейсхолдер не является меткой. Он исчезает при вводе, у него слабый контраст, и экранный диктор его не всегда читает.
<label for="issue_title">Название задачи</label>
<input id="issue_title" name="title" type="text">Если метку не хочется показывать визуально, её прячут классом для экранных читалок, а не удаляют.
Отдельный случай — поля поиска и фильтров, где метка занимает место. Там подходит aria-label, но с оговоркой: он должен совпадать с тем, что видит зрячий пользователь. aria-label="Поиск по задачам" при плейсхолдере «Поиск» — нормально. При плейсхолдере «Введите запрос» и метке «Фильтр по исполнителю» — это два разных интерфейса для двух групп пользователей.
Ошибки, связанные с полем
Красная рамка вокруг поля не сообщает ничего тому, кто её не видит. Нужна явная связь.
<label for="email">Email</label>
<input id="email" name="email" type="email"
aria-invalid="true" aria-describedby="email_error">
<p id="email_error" class="text-error">Адрес уже используется</p>Три атрибута, и поле становится понятным. aria-invalid говорит, что значение неверно, aria-describedby привязывает текст ошибки.
Результат отправки формы — отдельная история. При серверном рендере страница перезагружается, и содержимое читается заново. При частичном обновлении через фрейм — нет, изменения происходят тихо. Область с сообщением должна быть живой:
<div role="status" aria-live="polite">
{% if flash %}<p>{{ flash }}</p>{% endif %}
</div>Надёжнее держать пустой элемент с aria-live в разметке заранее и менять его содержимое. Вставка уже заполненной живой области поддерживается читалками не одинаково, поэтому такое объявление надо проверять в целевых сочетаниях браузера и экранного диктора.
Клавиатура в виджетах
Здесь чаще всего и ломается, потому что мышью всё работает.
Проверка одна: пройти экран от начала до конца клавишей табуляции, не трогая мышь. Занимает минуту.
Что вылезает регулярно:
- кликабельный
divвместо кнопки — фокус на него не встаёт вовсе; - выпадающее меню открывается, но стрелки по пунктам не ходят и
Escapeне закрывает; - модальное окно открылось, а фокус остался на странице под ним, и табуляция уходит в невидимое содержимое;
- порядок обхода не совпадает с визуальным, потому что колонки переставлены сеткой.
Первое лечится заменой на <button type="button">. Четвёртое — тем, что порядок в разметке должен совпадать с визуальным порядком, а не подгоняться свойствами сетки.
Модальное окно требует изолировать фон от фокуса. Нативный элемент диалога делает остальной документ неактивным и обрабатывает Escape:
this.dialog.showModal();Но начальный фокус, его возврат на кнопку открытия и поведение сложного содержимого всё равно надо проверить. Для своего диалога на div c оверлеем придётся дополнительно реализовать модальную семантику, замкнуть табуляцию внутри и не дать взаимодействовать со страницей под ним.
Фокус после навигации Turbo
Специфичная поломка серверного рендера с частичным обновлением, о которой не пишут в общих руководствах.
При обычной навигации браузер сбрасывает фокус на начало документа и объявляет новую страницу. Turbo подменяет содержимое — документ формально не менялся. Фокус остаётся там, где был, и это может быть удалённый элемент. Экранный диктор молчит: с его точки зрения ничего не произошло.
Лечится объявлением смены страницы:
document.addEventListener('turbo:load', () => {
const heading = document.querySelector('h1');
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus({ preventScroll: true });
}
});Фокус на заголовке возвращает точку отсчёта и заставляет прочитать название новой страницы. tabindex="-1" делает заголовок фокусируемым программно, но не через табуляцию.
Для фреймов такое не нужно: обновилась часть страницы, а не вся. Там нужна aria-live область, если изменение существенное.
Контраст, особенно в тёмной теме
В WCAG 2.2 минимальное отношение контраста — 4.5:1 для обычного текста и 3:1 для крупного: от 24 CSS-пикселей обычного начертания или примерно от 18.7 пикселя полужирного. Для визуально необходимой границы элемента управления ориентир тоже 3:1 относительно соседних цветов.
Тёмная тема проваливает контраст чаще светлой, и почти всегда на приглушённом тексте. Серый, который на белом даёт 5.1, на тёмно-синем фоне легко оказывается на 3.4.
Проверяю четыре пары при каждом изменении палитры: основной текст на фоне страницы, основной на поверхности карточки, приглушённый на поверхности, акцент на поверхности. Обе темы, восемь измерений, две минуты.
Отдельная ловушка — акцентный цвет как цвет ссылки. Цвет, отлично работающий фоном кнопки с белым текстом, в роли текста на белом фоне часто даёт 3.0–3.5. Нужен отдельный, более тёмный оттенок для текстовой роли.
И состояние фокуса. Обводка по умолчанию у браузеров разная, и её часто убирают ради вида. Убирать можно, только если сразу же нарисовать свою:
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 3px;
}:focus-visible вместо :focus — чтобы обводка не появлялась при клике мышью, только при клавиатуре.
Что ловит автоматика и что нет
Автоматические проверки находят только часть проблем: отсутствующие альтернативные тексты, некоторые случаи недостаточного контраста, дубли идентификаторов, поля без доступного имени и подозрительную иерархию заголовков.
Не находят они самого важного: что порядок обхода бессмысленный, что альтернативный текст есть, но написан как «изображение1», что модальное окно не запирает фокус, что живая область объявляет изменения на каждый чих и мешает работать.
Мой набор простой: расширение для браузера на быструю проверку и одна ручная проверка табуляцией. Всё остальное — по ситуации.
Чек-лист на один экран
- Есть основной
h1, а остальные заголовки отражают структуру. - У каждого поля есть доступное имя:
label,aria-labelledbyили обоснованныйaria-label. - Ошибки связаны с полями, изображения имеют осмысленный
alt, интерактивные элементы — понятные имя и роль. - Экран проходится табуляцией, порядок совпадает с визуальным.
- Модальное окно изолирует фон, получает и возвращает фокус.
- Фокус виден; необязательное движение отключается через
prefers-reduced-motion. - Контраст проверен в обеих темах.
Семь пунктов обычно укладываются в несколько минут на знакомом экране. Они заметно дешевле переделки готового продукта, но закрывают только базовый слой: перед выпуском критичных сценариев всё равно нужны ручные проверки и тестирование с ассистивными технологиями.