Как 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 заменяет нативную: спрашивайте себя, что элемент потерял.
  • Состояние обновляйте в той же строке, где меняете вид.
  • Автотесты ловят синтаксис, вранье ловит только проверка скринридером.
Проверьте себя
1. Чтобы «выключить» фон под модальным окном, разработчик поставил на контейнер страницы aria-hidden="true". Что произойдёт?
AНичего плохого: фон корректно скрыт и от скринридера, и от клавиатуры
BПользователь сможет уйти по Tab на кнопки фона, но скринридер о них ничего не скажет
CФон станет недоступен для мыши, но останется доступным для скринридера
DБраузер автоматически заблокирует фокус внутри модального окна
2. Почему выражение «ARIA хуже, чем отсутствие ARIA» верно для div с role="button" без обработки клавиатуры?
AПотому что лишние атрибуты замедляют работу скринридера
BПотому что браузер перестаёт обрабатывать клик мышью на таком элементе
CПотому что скринридер объявит элемент кнопкой, пользователь нажмёт Enter, ничего не произойдёт — и он решит, что сломан сайт или он сам
DПотому что валидатор HTML считает role="button" на div ошибкой разметки