Как ARIA всё ломает
Разбираем фразу, которую в сообществе доступности повторяют как мантру: «No ARIA is better than bad ARIA» — отсутствие ARIA лучше, чем плохая ARIA.
Неправильная ARIA не просто бесполезна. Она активно вредит: подменяет верную информацию на ложную, и пользователь принимает решения, исходя из вранья.
Логика жёсткая и от неё некуда деться. Если ARIA нет, скринридер честно скажет: «Купить, текст». Пользователь поймёт, что перед ним не кнопка, и станет искать её глазами курсора — по странице, по ссылкам, по заголовкам. Неудобно, но он справится. Если же на этом же элементе висит role="button", скринридер объявит «Купить, кнопка». Пользователь нажмёт Enter, ничего не произойдёт, и он решит, что сайт сломан. Или что сломан он сам.
Ложь дороже молчания. Это и есть смысл фразы про плохую ARIA.
Зачем это на практике
Ежегодный отчёт WebAIM Million, где автоматически проверяют миллион главных страниц, из года в год фиксирует неудобный факт: на страницах, где используется ARIA, автоматических ошибок доступности в среднем больше, чем на страницах без неё. Причина не в том, что ARIA плоха — просто её берут в руки на сложных интерфейсах и почти всегда применяют неверно. Чем больше атрибутов, тем больше поводов соврать.
Вывод для команды: добавление ARIA — это не «сделали лучше по определению». Это правка, которую нужно проверять так же, как код с деньгами.
Антипаттерн 1. role="button" на div без клавиатуры
Живая классика. Встречается в каждом втором проекте, куда доступность добавляли в спешке перед аудитом.
<!-- Плохо: роль есть, кнопки нет -->
<div class="btn" role="button" onclick="submit()">Отправить</div>Что здесь не так, по пунктам: элемент не попадает в фокус по Tab (нет tabindex); Enter и Space не срабатывают (onclick у div реагирует только на мышь и на тап); нет кольца фокуса; кнопка не подсвечивается в режиме высокой контрастности Windows. Роль пообещала всё это — не выполнено ничего.
Допустим, вы упрямы и хотите оставить div. Тогда честная реализация выглядит так:
<div class="btn" role="button" tabindex="0" id="send">Отправить</div>const el = document.getElementById('send');
el.addEventListener('click', submit);
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); // иначе пробел прокрутит страницу
submit();
}
});Восемь строк JavaScript, отдельный CSS для :focus-visible, ручная обработка disabled, и всё это придётся тащить в каждый компонент. Или одна строка:
<button type="button" class="btn">Отправить</button>Антипаттерн 2. aria-hidden на фокусируемом элементе
Атрибут aria-hidden="true" вырезает узел (и всё его поддерево) из дерева доступности. Из порядка фокуса он не вырезает ничего. Получается «фантомный фокус»: Tab приводит пользователя на элемент, который для скринридера не существует. Программа не может сказать ничего — она просто молчит.
<!-- Плохо: фокус придёт, а объявить будет нечего -->
<button aria-hidden="true">Пропустить</button>
<!-- Плохо: то же самое, но с целым поддеревом -->
<div class="page" aria-hidden="true">
<a href="/help">Помощь</a>
<button>Меню</button>
</div>Второй случай — самый частый: так пытаются «выключить» фон под модальным окном. Фон исчезает для скринридера, но по Tab пользователь спокойно уходит в него и проваливается в тишину.
Правильный инструмент — атрибут inert. Он делает ровно то, что от него ждут: убирает поддерево и из дерева доступности, и из порядка фокуса, и из-под мыши.
<div class="page" inert>
<a href="/help">Помощь</a>
</div>
<dialog open aria-labelledby="dlg-title">
<h2 id="dlg-title">Подтвердите удаление</h2>
<button>Удалить</button>
<button>Отмена</button>
</dialog>А вот законное применение aria-hidden: декоративная иконка внутри кнопки, у которой уже есть текстовое имя. Скрывать нечего, кроме шума.
<button>
<svg aria-hidden="true" focusable="false">…</svg>
Скачать
</button>Антипаттерн 3. Дублирование и переопределение ролей
Здесь два разных греха. Первый — безобидный, но выдаёт непонимание:
<button role="button">Ок</button>
<nav role="navigation">…</nav>
<input type="checkbox" role="checkbox">Роль уже есть у элемента, атрибут ничего не добавляет. Мусор.
Второй грех — настоящая поломка, когда ARIA переопределяет роль и уносит с собой поведение:
| Плохо | Что сломалось |
<a href="/cart" role="button"> | Скринридер обещает кнопку, а элемент открывается в новой вкладке по Ctrl+клик и ведёт себя как ссылка |
<table role="presentation"> на таблице с данными | Пропадает навигация по строкам и столбцам |
<h2 role="presentation"> | Заголовок исчезает из списка заголовков — рушится карта страницы |
<li role="button"> | Ломается объявление «список из 5 элементов» |
Проверочный вопрос перед тем, как поставить роль: что элемент умел до этого и умеет ли он это до сих пор?
Антипаттерн 4. Состояние, которое врёт
Самый коварный случай, потому что автоматические проверки его не ловят: разметка формально валидна.
<button aria-expanded="false" aria-controls="menu">Каталог</button>Меню открывается, класс is-open добавляется, CSS отрабатывает — а aria-expanded так и остался false, потому что в JS его никто не трогает. Скринридер продолжает объявлять «свёрнуто» над раскрытым меню. Пользователь верит скринридеру. Он не найдёт каталог.
Ровно та же история с aria-checked на кастомном переключателе и aria-selected на вкладках. Железное правило: каждое состояние ARIA обязано меняться в том же месте кода, где меняется визуальное состояние. Если добавляете класс — рядом ставьте setAttribute. Не в другой функции, не «потом» — в той же строке.
Как это работает
Все четыре антипаттерна — одна и та же ошибка в разных обличьях. ARIA описывает обещанную модель интерфейса, а браузер и DOM живут по фактической. Между ними нет связи: никто не проверит, что роль button подкреплена обработчиком клавиш, а aria-expanded="true" — реально открытой панелью.
Синхронизация двух моделей целиком на разработчике. Как только они разошлись, страница начинает врать — и врать уверенно, потому что скринридер не сомневается в том, что ему сказали.
Отсюда — практическая проверка перед каждым ARIA-атрибутом:
- Есть ли нативный элемент, который делает то же самое? Если да — использую его и закрываю вопрос.
- Что элемент умел до моей роли и умеет ли он это теперь?
- Работает ли виджет с клавиатуры: Tab, Enter, Space, стрелки — то, что ожидается от этой роли?
- Кто и где меняет состояние, когда меняется вид?
- Прошёл ли я по этому виджету реальным скринридером — VoiceOver на macOS (Cmd+F5) или NVDA на Windows (бесплатный)? Пятнадцать минут проверки заменяют неделю споров.
Частые ошибки
- «Добавим ARIA — станет доступнее». Не станет. Станет либо точнее, либо лживее.
- ARIA как затычка вместо рефакторинга. Обвешать
divатрибутами быстрее, чем починить компонент, — и дороже в поддержке. - Доверие только автоматике. axe и Lighthouse ловят синтаксис (несуществующий
idвaria-labelledby, недопустимое значение), но не ловят вранье:aria-expanded, который не обновляется, для них выглядит нормально. - Копипаст ARIA из чужого кода. Атрибуты из примера комбобокса, вставленные в выпадающее меню, описывают чужой виджет.
- Роль без имени.
role="dialog"безaria-labelилиaria-labelledbyзвучит как «диалог» — и всё. role="dialog"без фокус-трапа. Роль есть, а фокус уходит на фон под окном.
Итоги
- Плохая ARIA хуже, чем никакой: она заменяет честное умолчание уверенной ложью.
- Роль без клавиатуры — обещание, которое элемент не выполнит.
aria-hiddenубирает из дерева доступности, но не из порядка фокуса; для фона под модальным окном беритеinert.- Роль ARIA заменяет нативную: спрашивайте себя, что элемент потерял.
- Состояние обновляйте в той же строке, где меняете вид.
- Автотесты ловят синтаксис, вранье ловит только проверка скринридером.