Доступность на фронтенде: рабочие приёмы в CSS и JS
Модалка, которую нельзя закрыть с клавиатуры, кнопка-div без роли, фокус, улетающий в фон. Разбираем приёмы, которые чинят реальные проблемы доступности без переписывания вёрстки.

Скринридер VoiceOver зачитывает «button, button, button» вместо понятных названий. Пользователь с клавиатурой открыл модалку, нажал Tab — и фокус ушёл на ссылки под затемнением, которое он даже не видит. Кнопка на <div> с обработчиком onClick вообще не реагирует на Enter. Всё это ломается не потому, что доступность сложна, а потому, что стандартные механизмы браузера отключили руками и не восстановили. Ниже — конкретные приёмы для типичных фронтенд-задач: фокус, состояние, скрытый текст, интерактивные элементы. Проверял в Chrome 120+, Firefox, Safari, с VoiceOver и NVDA.
Первое правило: семантический тег вместо ARIA
Самая частая ошибка — интерактивный элемент на неинтерактивном теге. <div class="button"> с обработчиком мыши выглядит как кнопка, но браузер о нём ничего не знает: он не в порядке табуляции, не срабатывает на Enter и Space, скринридер не объявляет его как кнопку.
Чтобы <div> стал полноценной кнопкой вручную, нужно четыре вещи: role="button", tabindex="0", обработчик клавиатуры на Enter и Space, управление aria-disabled. Нативный <button> даёт всё это бесплатно.
Первое правило ARIA из спецификации W3C звучит буквально так: если можно использовать нативный HTML-элемент с нужной семантикой и поведением — используйте его, а не переопределяйте роль через ARIA.
Если по каким-то причинам тег заменить нельзя (легаси-вёрстка, компонент из чужой библиотеки), минимальный набор для «кнопки на div» выглядит так:
<!-- Так делать не надо, но если очень нужно -->
<div
role="button"
tabindex="0"
onClick="doAction()"
onKeyDown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); doAction(); }"
>
Отправить
</div>
Сравните с <button onClick="doAction()">Отправить</button> — и станет ясно, зачем спецификация настаивает на нативных тегах.
Скрытие контента: три разных «спрятать»
«Скрыть» на фронтенде означает минимум три разных вещи, и путаница между ними ломает доступность. Элемент можно спрятать визуально, но оставить для скринридера; спрятать от скринридера, но оставить видимым; спрятать полностью.
| Задача | Как | Виден глазом | Виден скринридеру |
|---|---|---|---|
| Убрать совсем | display: none или hidden | Нет | Нет |
| Только для скринридера (visually hidden) | класс .sr-only | Нет | Да |
| Декоративный элемент, скрыть от скринридера | aria-hidden="true" | Да | Нет |
Класс для «визуально скрытого, но озвучиваемого» текста — рабочая практика для подписей к иконкам и контекста для ссылок «Подробнее». Ключевой момент: нельзя использовать display: none или width: 0; height: 0, потому что это уберёт элемент и из озвучки. Проверенный вариант:
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
Применение: <a href="/article/42">Читать <span class="sr-only">статью про кеширование</span></a>. Глазами видно «Читать», скринридер прочтёт «Читать статью про кеширование» — и десять таких ссылок на странице перестанут звучать одинаково.
aria-hidden="true" — обратная задача: иконка-стрелка рядом с текстом кнопки не должна озвучиваться отдельно. Важно: не вешайте aria-hidden на элемент, внутри которого есть фокусируемые элементы, — фокус на них попадёт, а озвучки не будет.
Управление фокусом: :focus-visible вместо отключения
Дизайнеры не любят синюю рамку вокруг кнопок и часто просят outline: none. Убрать её глобально — значит лишить клавиатурного пользователя единственного индикатора: где он сейчас находится. Компромисс появился с :focus-visible.
Браузер сам различает, пришёл фокус с клавиатуры или мыши. :focus-visible срабатывает только при клавиатурной навигации, поэтому рамку можно оставить для тех, кому она нужна, и убрать при клике мышью:
/* Убираем стандартный outline при клике мышью */
button:focus:not(:focus-visible) {
outline: none;
}
/* Возвращаем заметный индикатор при навигации с клавиатуры */
button:focus-visible {
outline: 2px solid #0057d8;
outline-offset: 2px;
}
:focus-visible поддерживается всеми актуальными версиями Chrome, Firefox и Safari (Safari — с 15.4). Если нужна поддержка совсем старых браузеров, оставьте базовый :focus как запасной.
Ловушка фокуса в модалке
Открыли модальное окно — фокус должен переместиться внутрь и не уходить в фон, пока окно открыто. По Escape окно закрывается, и фокус возвращается на элемент, который его открыл. Это не украшение: без этого клавиатурный пользователь просто «проваливается» сквозь модалку в недоступный ему контент.
Схематично логика такая: при открытии запоминаем активный элемент, переводим фокус на первый интерактивный внутри окна, на Tab и Shift+Tab зацикливаем фокус между первым и последним фокусируемым элементом, на Escape закрываем и возвращаем фокус. Полноценная реализация ловушки фокуса — заметный объём кода с обработкой краевых случаев, поэтому для продакшена разумнее взять проверенную библиотеку, например focus-trap, а не писать своё. Минимальная версия для понимания механизма:
// Схематично: показывает принцип, в проде используйте focus-trap
function openModal(modal, triggerButton) {
const focusable = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
modal.hidden = false;
first.focus();
modal.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
modal.hidden = true;
triggerButton.focus(); // вернули фокус на кнопку-открывашку
}
if (event.key === 'Tab') {
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
});
}
Модалке также нужны role="dialog", aria-modal="true" и aria-labelledby, указывающий на её заголовок, — иначе скринридер не объявит, что открылось окно.
Динамические изменения: aria-live
Скринридер зачитывает страницу линейно. Если после нажатия «Отправить» под формой появился текст «Сообщение отправлено», пользователь скринридера об этом не узнает — фокус остался на кнопке, а появившийся текст ниже он не читает. Решение — aria-live: браузер сам объявит изменение содержимого такого контейнера.
aria-live="polite"— объявит, когда скринридер закончит текущую фразу. Для статусов, уведомлений, результатов поиска.aria-live="assertive"— прервёт текущую озвучку немедленно. Только для критичного: ошибка отправки, потеря соединения.
Контейнер с aria-live должен существовать в DOM заранее, пустым. Если создавать его вместе с текстом, часть скринридеров не успеет отследить изменение:
<!-- Пустой контейнер уже в разметке -->
<div id="status" aria-live="polite"></div>
// Позже просто меняем текст внутри
document.getElementById('status').textContent = 'Сообщение отправлено';
Что ломается в проде
- aria-label затирает содержимое. Если у ссылки есть и текст, и
aria-label, скринридер прочитает толькоaria-label. Люди дублируют текст в лейбл с опечаткой — и озвучка расходится с видимым. - Контраст ниже нормы. Светло-серый текст на белом визуально читается, но не проходит порог контрастности WCAG (для обычного текста — 4.5:1). Проверяйте в DevTools: панель проверяет контраст прямо в пикере цвета.
- Плейсхолдер вместо label. Плейсхолдер исчезает при вводе и не всегда озвучивается как подпись поля. Полю нужен настоящий
<label>, связанный черезfor. - tabindex больше нуля. Положительный
tabindexломает естественный порядок обхода и почти всегда создаёт больше проблем, чем решает. Используйте только0и-1. - prefers-reduced-motion игнорируется. Резкие анимации вызывают у части пользователей тошноту и головокружение. Оборачивайте их в
@media (prefers-reduced-motion: reduce)и отключайте.
Что делать дальше
Пройдитесь по своей странице только с клавиатуры, без мыши: Tab, Shift+Tab, Enter, Escape. Если куда-то нельзя добраться или фокус теряется — это первое, что чинить. Дальше включите VoiceOver (Cmd+F5 на macOS) или NVDA на Windows и послушайте страницу. Автоматические проверки — axe DevTools или Lighthouse во вкладке доступности — ловят контраст, отсутствие label и пустые ссылки, но не заметят логических проблем вроде фокуса, уходящего в фон. Живая проверка клавиатурой и скринридером остаётся обязательной.
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: WAI-ARIA Authoring Practices Guide (W3C), MDN: :focus-visible
Частые вопросы
Достаточно ли прогнать страницу через Lighthouse, чтобы считать её доступной?
А если мне нужна поддержка старых браузеров, где нет :focus-visible?
Почему aria-live не срабатывает, хотя я всё сделал по инструкции?
Можно ли просто везде расставить role и aria-label и не трогать вёрстку?
Нужно ли aria-hidden ставить на все декоративные иконки?
Чем плох положительный tabindex, если он вроде бы работает?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.