Пять правил ARIA (и главное из них)

ARIA умеет переименовать элемент для скринридера, но не умеет научить его работать. Отсюда растут все пять правил — и все беды тех, кто их не знает.

ARIA (Accessible Rich Internet Applications) — набор HTML-атрибутов, который правит представление элемента в дереве доступности: его роль, имя и состояние. Ни внешний вид, ни поведение элемента ARIA не меняет.

Фраза «не меняет поведение» звучит как формальность, пока не увидишь последствия. Вы пишете <div role="button">Купить</div>. Скринридер честно объявляет: «Купить, кнопка». Пользователь нажимает Enter — ничего не происходит. Точнее, фокус на этот div даже не встаёт: до Enter дело не доходит. Роль пообещала кнопку, а кнопки нет.

ARIA — это подпись на коробке, а не содержимое коробки. Подпись читают вспомогательные технологии: скринридеры (NVDA, JAWS, VoiceOver), голосовое управление, брайлевские дисплеи. Браузер на неё почти не реагирует: он не добавит обработчик клавиш, не сделает элемент фокусируемым, не изменит курсор. Всё это остаётся на вас.

Зачем это на практике

Современный макет собирается из <div>. Дизайнер прислал карточку с «кнопкой» — прямоугольник со скруглением; вёрстку делают дивом, потому что так проще победить стили. Через полгода приходит требование доступности, и у команды два пути: вернуть нативные элементы или подписать существующие дивы атрибутами ARIA.

Первый путь почти всегда дешевле. Нативная <button> бесплатно приносит фокус по Tab, срабатывание по Enter и Space, роль «кнопка», состояние «недоступна» при disabled, участие в форме и поведение в режиме высокой контрастности Windows. Всё это придётся написать руками, если вы выбрали div.

ARIA нужна там, где в HTML просто нет подходящего тега: вкладки, комбобокс с автодополнением, дерево, слайдер с двумя ручками, всплывающая подсказка, область, куда прилетают новые сообщения. Это язык для того, чего HTML не умеет, а не запасной способ описать то, что он умеет.

Пять правил ARIA

Правила сформулированы рабочей группой W3C в документе «Using ARIA». Порядок не случайный: первое весит больше остальных четырёх вместе.

Правило 1. Не используй ARIA, если есть нативный элемент

Если существует HTML-элемент с нужной семантикой и поведением — берите его, даже если придётся повоевать со стилями.

<!-- Плохо: три атрибута и обработчики впереди -->
<div role="button" tabindex="0" onkeydown="...">Отправить</div>

<!-- Хорошо: то же самое, но всё работает из коробки -->
<button type="submit">Отправить</button>

Обычно возражают: «нативную кнопку не застилизуешь». Это миф родом из 2010-х. Четыре строки CSS — и <button> выглядит как угодно.

button.reset {
  all: unset;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
}

Правило 2. Не переопределяй нативную семантику без крайней нужды

Роль ARIA не добавляется к роли элемента — она её заменяет. Заголовок с ролью вкладки перестаёт быть заголовком: он пропадает из списка заголовков, по которому слепые пользователи ориентируются на странице (в NVDA это клавиша H).

<!-- Плохо: заголовок исчез из структуры страницы -->
<h2 role="tab">Доставка</h2>

<!-- Хорошо: роль на отдельном элементе внутри -->
<h2><button role="tab" aria-selected="true">Доставка</button></h2>

Самый разрушительный случай — role="presentation" (или его синоним role="none") на таблице с данными. Он снимает роль таблицы вместе со всей навигацией по строкам и столбцам: пользователь больше не может спросить «в какой я колонке?». Ставьте его только на таблицы, которые используются для раскладки (в письмах такое ещё встречается).

Правило 3. Любой интерактивный ARIA-виджет обязан работать с клавиатуры

Если вы назначили роль button, tab, menuitem, checkbox — вы взяли на себя обязательство реализовать клавиатуру: фокус через tabindex, нужные клавиши, видимое кольцо фокуса. Роль без клавиатуры — обещание, которое элемент не может выполнить.

Правило 4. Не прячь от скринридера то, на что можно поставить фокус

Ни aria-hidden="true", ни role="presentation" не должны стоять на фокусируемом элементе или его предке. Иначе фокус приходит на элемент, которого для скринридера не существует, и пользователь слышит тишину. Об этом «фантомном фокусе» — отдельный урок в конце раздела.

Правило 5. У каждого интерактивного элемента должно быть доступное имя

Имя — то, что скринридер произносит вслух. У кнопки с текстом имя берётся из содержимого. У иконочной кнопки содержимого нет, и без явного имени она озвучится как «кнопка» — угадывайте, что она делает.

<!-- Плохо: имени нет, скринридер скажет просто «кнопка» -->
<button><svg aria-hidden="true">…</svg></button>

<!-- Хорошо: иконка скрыта, имя задано текстом -->
<button aria-label="Удалить комментарий">
  <svg aria-hidden="true" focusable="false">…</svg>
</button>

Роли, состояния и свойства

Атрибуты ARIA делятся на три группы, и путаница между ними — источник половины ошибок.

ГруппаПримерЧто описываетМеняется во времени?
Рольrole="tab", role="dialog"Что это за штукаПрактически никогда
Свойствоaria-label, aria-controls, aria-requiredУстойчивую характеристикуРедко
Состояниеaria-expanded, aria-checked, aria-disabled, aria-invalidТекущее положение делПостоянно — это ваша работа

Состояние без JavaScript мертво. Аккордеон, у которого aria-expanded="false" прописано в разметке и никогда не обновляется, врёт пользователю на каждом клике: панель открыта, а скринридер продолжает объявлять «свёрнуто».

<button aria-expanded="false" aria-controls="panel-1">Условия доставки</button>
<div id="panel-1" hidden>
  <p>Доставим за 2 дня по городу.</p>
</div>
const btn = document.querySelector('[aria-controls="panel-1"]');
const panel = document.getElementById('panel-1');

btn.addEventListener('click', () => {
  const open = btn.getAttribute('aria-expanded') === 'true';
  btn.setAttribute('aria-expanded', String(!open));
  panel.hidden = open;
});

Здесь важна пара: aria-expanded висит на кнопке, а не на панели, и указывает на панель через aria-controls. Атрибут на панели скринридер просто не увидит — пользователь стоит на кнопке.

aria-label против aria-labelledby

Оба атрибута задают доступное имя, но по-разному.

aria-labelaria-labelledby
Что принимаетСтроку текстаОдин или несколько id через пробел
Видно на экранеНетДа — берётся из существующего текста
Когда братьВидимой подписи нет (иконка, лендмарка)Подпись уже есть на странице
ПриоритетНижеВыше — перебивает aria-label
<!-- Имя собирается из двух узлов: «Удалить Отчёт за март» -->
<h3 id="file-7">Отчёт за март</h3>
<button id="del-7" aria-labelledby="del-7 file-7">Удалить</button>

Приём с перечислением нескольких id (включая собственный) спасает списки, где двадцать одинаковых кнопок «Удалить»: скринридер прочтёт каждую вместе с именем файла.

Правило выбора: если подпись видна глазами — aria-labelledby. Если подписи нет вообще — aria-label. Порядок вычисления имени такой: aria-labelledby, затем aria-label, затем нативная подпись (<label>, alt, содержимое элемента), затем title — как последняя надежда.

Отдельная ловушка: aria-label не должен противоречить видимому тексту. Кнопка с надписью «Отправить» и aria-label="Сохранить черновик" ломает голосовое управление — пользователь говорит «нажми Отправить», и система его не понимает, потому что для неё элемент называется иначе. Это нарушение критерия WCAG 2.5.3 «Label in Name»: видимый текст должен входить в доступное имя.

Как это работает

Браузер строит из DOM второе дерево — дерево доступности. В нём остаются только смысловые узлы, и у каждого есть роль, имя, состояние, значение. Скринридер читает именно его, а не вашу вёрстку.

ARIA-атрибуты вмешиваются ровно на этом шаге: они правят поля узла. Роль перезаписывает роль, aria-label перезаписывает имя, aria-expanded добавляет состояние. Обработчики событий, порядок фокуса, стили — всё это живёт в DOM и остаётся нетронутым.

Отсюда главный вывод урока: ARIA ничего не чинит. Она сообщает. Если сообщение не совпадает с реальностью — вы сделали хуже, чем если бы промолчали. В следующем уроке этого раздела разберём приятное исключение из правила «ARIA ничего не делает» — живые области, которые действительно заставляют скринридер заговорить.

Частые ошибки

  • ARIA вместо HTML. <div role="link"> вместо <a href>: нет перехода по Enter, нет открытия в новой вкладке, нет статуса «посещённая».
  • aria-label на неинтерактивном элементе. На обычном <div> или <span> без роли имя чаще всего игнорируется — у элемента нет роли, к которой его можно привязать.
  • Состояние задано в разметке и забыто в JS. Классика с aria-expanded и aria-checked.
  • Опечатка в значении. aria-expanded="True" или aria-hidden="yes" — значения регистрозависимы, допустимы только true и false.
  • aria-labelledby на несуществующий id. Ссылка в никуда — имя пропадает совсем, и элемент становится безымянным.
  • Дублирование имени. <button aria-label="Поиск">Поиск</button> — атрибут просто лишний.

Итоги

  • ARIA правит дерево доступности: роль, имя, состояние. Поведение остаётся на вас.
  • Первое правило важнее остальных: есть нативный элемент — берите его.
  • Роль ARIA заменяет нативную семантику, а не дополняет её.
  • Роли и свойства статичны, состояния нужно синхронизировать в JavaScript при каждом изменении.
  • Видимая подпись есть — aria-labelledby; подписи нет — aria-label; aria-labelledby перебивает aria-label.
  • Имя не должно противоречить видимому тексту, иначе ломается голосовое управление.
Проверьте себя
1. На странице есть <div role="button" tabindex="0">Купить</div> без обработчиков клавиатуры. Что услышит и получит пользователь скринридера?
AУслышит «Купить, кнопка», сможет поставить фокус, но нажатие Enter ничего не сделает
BЭлемент вообще не попадёт в дерево доступности
CБраузер сам добавит обработку Enter и Space, потому что указана роль button
DСкринридер объявит элемент как обычный текст, роль будет проигнорирована
2. У кнопки одновременно заданы aria-label="Закрыть окно" и aria-labelledby, указывающий на видимый заголовок «Настройки». Какое имя получит элемент?
AОба будут прочитаны подряд: «Закрыть окно Настройки»
B«Настройки» — aria-labelledby имеет более высокий приоритет
C«Закрыть окно» — явно заданная строка всегда приоритетнее
DИмя не определено, скринридер прочитает содержимое кнопки