ARIA: когда нужна, когда вреднее не использовать
Самый частый паттерн, который я вижу в попытках «улучшить доступность» — массовое навешивание ARIA-атрибутов на всё подряд. role="button" на ссылке, aria-label="Кнопка" на кнопке с текстом «Сохранить», aria-describedby на пустую div. Все три случая — антипаттерны. ARIA не делает интерфейс доступнее «по умолчанию». Чаще наоборот: неправильно использованная ARIA выдаёт скринридеру неверную информацию, и пользователь дезориентирован.
Эта статья — про то, когда ARIA реально нужна, когда она просто шум, и где её использование делает только хуже. Без копипасты ARIA Authoring Practices, на которые часто ссылаются и из которых половина рекомендаций устарела.
Первое правило ARIA: не использовать ARIA
В спецификации ARIA это сформулировано почти дословно: «Если можешь использовать нативный HTML-элемент с нужной семантикой и поведением вместо ARIA — используй HTML».
Что это значит на практике. В 90% случаев правильный HTML-тег решает задачу полностью. <button> — это уже role="button", ловит Enter и Space, доступен через Tab, имеет состояние disabled. <input type="checkbox"> — это уже role="checkbox" с состоянием checked. <a href> — это role="link".
Если на странице ты видишь <div role="button" tabindex="0" onClick={...}> — почти всегда это ошибка. Замени на <button>.
Когда без ARIA не обойтись:
- Виджеты, которым нет HTML-эквивалента: tab, dialog, combobox, tree, slider (в некоторых случаях), tooltip.
- Динамическая информация о состоянии:
aria-expanded,aria-pressed,aria-current. - Связь между разрозненными элементами:
aria-describedby,aria-labelledby,aria-controls. - Live-регионы для асинхронных уведомлений:
aria-live. - Скрытие декоративных элементов:
aria-hidden="true".
aria-label: куда не нужно ставить
Самое частое злоупотребление. Сценарий: разработчик хочет «улучшить доступность» и пишет на каждой кнопке aria-label с тем же текстом, что и внутри.
<!-- избыточно: -->
<button aria-label="Сохранить">Сохранить</button>aria-label заменяет текстовое содержимое для скринридера. То есть скринридер прочитает только «Сохранить», игнорируя текст внутри. Если они совпадают — оверхед. Если не совпадают — ловушка: визуально пользователь видит одно, а слышит другое.
aria-label оправдан только когда текста внутри нет или он неинформативен:
<button aria-label="Закрыть">
<svg aria-hidden="true">...</svg>
</button>
<button aria-label="Меню">
<span aria-hidden="true">☰</span>
</button>aria-labelledby vs aria-label
Разница простая: aria-label — строка-литерал, aria-labelledby — id другого элемента, чьё содержимое будет использовано как ярлык.
<section aria-labelledby="prefs-heading">
<h2 id="prefs-heading">Настройки уведомлений</h2>
...
</section>Когда выбирать что. Если ярлык уже есть на странице как видимый текст — aria-labelledby. Дублировать в строку не надо. Если визуального ярлыка нет (иконочная кнопка) — aria-label. Никогда не используй оба одновременно: aria-labelledby побеждает aria-label, и второй просто игнорируется.
Состояния: aria-expanded, aria-pressed, aria-current
Это ARIA, ради которой стоит её знать. Без этих атрибутов скринридер не понимает, в каком состоянии находится элемент.
aria-expanded — для раскрывающихся блоков (аккордеон, dropdown, навигационное меню):
<button
aria-expanded="false"
aria-controls="submenu-1"
>
Раздел
</button>
<ul id="submenu-1" hidden>
<li>...</li>
</ul>Когда раздел открывается — меняй на aria-expanded="true". Скринридер озвучит «развёрнуто» / «свёрнуто». Без этого пользователь не знает, что нажатие на кнопку что-то меняет.
aria-pressed — для toggle-кнопок (вкл/выкл, like/unlike). Не для radio-группы.
<button aria-pressed="false" onClick={toggle}>
Курсив
</button>aria-current — для текущей страницы в навигации, текущего шага в визарде:
<a href="/about" aria-current="page">О нас</a>Допустимые значения: page, step, location, date, time, true. Скринридер озвучит «текущая страница».
aria-hidden: точечный инструмент
Скрывает элемент от скринридера, но оставляет видимым визуально. Антипод display: none, который убирает элемент отовсюду.
Корректные применения:
- Декоративные иконки внутри кнопки:
<span aria-hidden="true">→</span> - Дублирующий счётчик в карточке, если число и так озвучивается через aria-label.
- Элементы за модалкой: когда модалка открыта, обернуть всё остальное в
<div inert aria-hidden="true">.
Что не делать. Не ставь aria-hidden="true" на интерактивный элемент. Если у тебя <button aria-hidden="true">, кнопка пропадёт из скринридера, но через Tab её всё ещё можно сфокусировать. Пользователь окажется на «невидимой» кнопке. Это баг доступности.
Если хочешь убрать что-то из tab-порядка — используй tabindex="-1" или атрибут inert. inert убирает и из скринридера, и из Tab — это правильный способ погасить блок.
aria-live: уведомления и асинхронность
Когда контент на странице меняется без явного действия пользователя (пришло уведомление, обновился счётчик, появилась ошибка валидации после отправки формы), скринридер по умолчанию это пропускает. Чтобы озвучить изменение, нужен live-регион.
<div aria-live="polite" aria-atomic="true" id="status"></div>Когда в этот div добавляется текст, скринридер его озвучит. Полностью или частично — зависит от aria-atomic.
aria-live="polite"— озвучит, когда пользователь закончит текущее действие. Для большинства уведомлений.aria-live="assertive"— прервёт текущую речь и озвучит немедленно. Для критических ошибок. Использовать редко.aria-atomic="true"— озвучить весь контент региона целиком. Без этого озвучатся только изменившиеся узлы, что часто звучит странно.
Типовая ошибка — заводить новый live-регион на каждое уведомление. Не работает: скринридер должен заранее «слышать» регион. Делай один глобальный регион в layout, и пиши в него:
function announce(message: string) {
const region = document.getElementById("a11y-status");
if (!region) return;
region.textContent = "";
// даём браузеру время осознать пустое состояние
requestAnimationFrame(() => {
region.textContent = message;
});
}Этот requestAnimationFrame — ключевой. Если не очистить регион перед записью, при двух одинаковых сообщениях подряд скринридер не озвучит второе (текст не изменился).
Где ARIA делает хуже
Несколько примеров из реальных аудитов.
role="button" на ссылке
<a href="#" role="button">Открыть модалку</a>Тут разработчик хотел сделать «кнопку», но почему-то остался тег a. Скринридер сообщает «кнопка», а на нажатие Enter элемент ведёт себя как ссылка (срабатывает href). Поведение и роль расходятся.
aria-label на нативных контролах
<input type="submit" value="Отправить" aria-label="Кнопка отправки">Скринридер озвучит «Кнопка отправки кнопка». Лишнее. Нативный value — уже текстовое имя кнопки.
role="presentation" на интерактивном
<button role="presentation">Закрыть</button>Кнопка перестаёт быть кнопкой для скринридера. Но всё ещё фокусируется. Получаем элемент без роли, на котором никак не понять «что это вообще».
aria-required на инпуте без HTML required
<input type="email" aria-required="true">Сообщает скринридеру «обязательное», но браузерная валидация не сработает. Пользователь, не видящий звёздочки, услышит «обязательное», а форма отправится с пустым значением. Используй HTML required, он уже даёт нужную семантику.
Как проверять
Я хожу по странице с открытым accessibility tree в Chrome DevTools (вкладка Elements → подвкладка Accessibility). Для каждого ARIA-атрибута на странице задаю вопрос:
- Что он добавляет, чего нет у нативного HTML?
- Что услышит скринридер?
- Совпадает ли роль ARIA с реальным поведением элемента?
Если на первый вопрос ответ «ничего» — атрибут лишний, удалить. Если на третий «нет» — атрибут вреден, переделать.
Из автоматики: axe-core ловит большинство неправильных применений ARIA. Подключается как Playwright-плагин или через расширение axe DevTools. Не идеален, но 80% типовых ошибок видит.
Что унести с собой
ARIA — точечный инструмент. Хороший фронтенд использует её там, где нативного HTML не хватает: для кастомных виджетов, для динамических состояний, для асинхронных уведомлений. Плохой фронтенд использует её, чтобы прикрыть отсутствие семантики или продублировать то, что и так есть.
Если в твоём проекте на странице сотни ARIA-атрибутов — это повод посмотреть, не подменяет ли она HTML, который изначально надо было использовать. div role="button" — это маркер плохого кода, а не «улучшение доступности».