ARIA: роли, состояния, свойства

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

ARIA (Accessible Rich Internet Applications) — набор HTML-атрибутов, которые сообщают скринридеру роль, состояние и свойства элемента, когда сама разметка их не выражает.

Первое правило ARIA: не используй ARIA

Звучит парадоксально, но это официальная рекомендация спецификации. Полностью она звучит так: «Если можешь использовать нативный HTML-элемент с нужной семантикой и поведением — используй его вместо того, чтобы переопределять элемент через ARIA».

Причина проста: нативные элементы уже несут роль и поведение, а ARIA только описывает, ничего не добавляя к функциональности. Поставив role="button" на <div>, вы пообещали скринридеру кнопку, но фокус и обработку клавиш всё равно придётся писать руками — иначе получится «кнопка», которая не нажимается.

<!-- Лишняя ARIA: тег и так несёт роль navigation -->
<nav role="navigation">...</nav>

<!-- Правильно: роль уже встроена в тег -->
<nav>...</nav>

Запомните: ARIA — это «пластырь» для случаев, когда нативного элемента нет (сложный виджет — вкладки, дерево, слайдер). Для обычной разметки она не нужна и чаще вредит.

Роли: role

Атрибут role сообщает, чем элемент является, перекрывая его исходную роль. Применять его осмысленно стоит лишь там, где готового тега нет:

<!-- Кастомный виджет вкладок: нативного тега нет, роли уместны -->
<div role="tablist">
  <button role="tab" aria-selected="true">Описание</button>
  <button role="tab" aria-selected="false">Отзывы</button>
</div>

Есть особая безобидная роль role="presentation" (или role="none") — она, наоборот, снимает семантику. Пригодится, если таблица используется не для данных, а для раскладки (хотя так лучше не делать).

Имя элемента: aria-label, aria-labelledby, aria-describedby

Чтобы скринридер прочитал элемент, у того должно быть имя. Обычно имя берётся из текста внутри. Но что, если внутри только иконка?

aria-label задаёт имя прямо строкой. Идеально для кнопок-иконок:

<!-- Внутри только крестик-иконка, текста нет — даём имя через aria-label -->
<button aria-label="Закрыть окно">
  <svg>...</svg>
</button>

aria-labelledby берёт имя из текста другого элемента по его id. Удобно, когда подпись уже есть на странице и дублировать её строкой не хочется:

<h2 id="dialog-title">Удалить аккаунт?</h2>
<div role="dialog" aria-labelledby="dialog-title">
  ...
</div>

aria-describedby добавляет к имени дополнительное описание (его скринридер читает после имени, как подсказку). Часто используется для текста ошибки или поясняющей подписи под полем:

<input id="pass" type="password" aria-describedby="pass-hint">
<p id="pass-hint">Минимум 8 символов</p>

Разница: labelledby — это что это за поле (имя), describedbyдополнительная подсказка к нему.

Состояния и свойства

ARIA умеет описывать не только «что это», но и «в каком оно состоянии». Эти атрибуты вы обязаны менять из JavaScript, когда состояние реально меняется.

aria-expanded — раскрыто или свёрнуто

Для аккордеонов, выпадающих меню, «спойлеров». Кнопка сообщает, раскрыт ли управляемый ею блок:

<button aria-expanded="false" aria-controls="menu">Меню</button>
<ul id="menu" hidden>
  <li>...</li>
</ul>

При клике скрипт переключает aria-expanded на "true" и показывает список. Скринридер тогда объявит: «Меню, кнопка, раскрыто».

aria-hidden — спрятать от скринридера

aria-hidden="true" убирает элемент из дерева доступности, оставляя его видимым на экране. Полезно для чисто декоративных иконок рядом с текстом:

<a href="/download">
  <svg aria-hidden="true">...</svg> Скачать
</a>

Иконка дублирует слово «Скачать», поэтому её незачем озвучивать дважды. Осторожно: никогда не ставьте aria-hidden="true" на фокусируемый элемент (ссылку, кнопку, поле) — получится фантом: с клавиатуры на него попасть можно, а скринридер про него молчит.

Live-регионы: озвучивание динамики

Когда контент меняется без перезагрузки (появилось сообщение «Сохранено», счётчик корзины, ошибка валидации), зрячий это видит, а пользователь скринридера — нет: его курсор в другом месте страницы. Решают это live-регионы.

Атрибут aria-live говорит: «следи за этим контейнером, и когда его содержимое изменится — прочитай вслух».

<!-- polite: дочитает текущую фразу, потом объявит. Для некритичных уведомлений -->
<div aria-live="polite"></div>

<!-- assertive: перебивает немедленно. Только для важного — ошибок, предупреждений -->
<div aria-live="assertive"></div>

Контейнер обычно пустой и присутствует в разметке заранее. Скрипт вставляет в него текст («Файл загружен») — и скринридер его озвучивает. Значение polite — выбор по умолчанию; assertive перебивает пользователя, поэтому его берегут для действительно срочного.

Как это работает под капотом

ARIA-атрибуты не меняют ни внешний вид, ни поведение элемента — они влияют только на дерево доступности. Браузер, формируя это дерево, подмешивает в роль, имя и состояние узла то, что вы указали в role, aria-label, aria-expanded и прочих атрибутах. Скринридер читает уже изменённое дерево. Поэтому role="button" заставит объявить элемент кнопкой, но Enter сам по себе не заработает — клавиатурную логику дерево доступности не добавляет, её пишет разработчик.

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

  • ARIA вместо семантики. <div role="button"> там, где напрашивается <button>. Лишняя работа и источник багов.
  • Дублирующие роли. <nav role="navigation">, <ul role="list"> — тег уже несёт эту роль.
  • Статичный aria-expanded. Поставили "false" в HTML и забыли переключать из JS — скринридер всегда говорит «свёрнуто», даже когда открыто.
  • aria-hidden="true" на фокусируемом элементе. Создаёт «фантом»: фокус есть, озвучки нет.
  • aria-live добавляют в момент изменения. Регион должен присутствовать в DOM заранее, иначе первое сообщение не озвучится.

Итоги

  • Первое правило ARIA — не использовать ARIA, если хватает нативного HTML.
  • ARIA только описывает (роль, имя, состояние), но не добавляет поведения — клавиатуру пишут руками.
  • aria-label задаёт имя строкой, aria-labelledby — из другого элемента, aria-describedby — дополнительную подсказку.
  • Состояния (aria-expanded, aria-hidden) нужно синхронно менять из JavaScript.
  • Live-регионы (aria-live="polite"/"assertive") озвучивают динамические изменения; контейнер готовят заранее.
Проверьте себя
1. Что утверждает «первое правило ARIA»?
AКаждый интерактивный элемент должен иметь атрибут role
BЕсли можно использовать нативный HTML-элемент с нужной семантикой — используй его вместо переопределения через ARIA
CARIA-атрибуты всегда улучшают доступность, поэтому их нужно добавлять везде
Drole нужно дублировать на каждом семантическом теге для надёжности
2. В чём разница между aria-labelledby и aria-describedby?
Alabelledby задаёт имя элемента (что это), а describedby — дополнительную подсказку к нему
BЭто синонимы, разницы нет
Clabelledby работает только с картинками, describedby — только с формами
Ddescribedby перекрывает имя, а labelledby его дополняет
3. Почему опасно ставить aria-hidden="true" на ссылку или кнопку?
AЭлемент исчезнет с экрана
BПолучится «фантом»: с клавиатуры на элемент можно попасть фокусом, но скринридер про него молчит
CБраузер выдаст ошибку и не отрендерит страницу
DНичего опасного, это рекомендуемый приём