ARIA и доступность в интерфейсах
Что такое ARIA
ARIA — Accessible Rich Internet Applications. Атрибуты для описания ролей и состояний.
Правило №1: не использовать ARIA, если есть нативный тег.
<!-- Плохо -->
<div role="button" tabindex="0" onclick="...">Кнопка</div>
<!-- Хорошо -->
<button type="button">Кнопка</button>
Правило №2: ARIA не меняет поведение. Только описывает.
Правило №3: ARIA для динамического контента, где HTML не хватает.
Основные роли
role — роль элемента:
<div role="button">Кнопка</div>
<div role="navigation">Меню</div>
<div role="dialog">Модалка</div>
<div role="alert">Уведомление</div>
<div role="tab">Вкладка</div>
<div role="tabpanel">Панель</div>
<div role="tooltip">Тултип</div>
<div role="status">Статус</div>
⚠️ Роли — не заменяют семантические теги.
Правило: роль только там, где нет тега.
aria-label
Текстовая метка элемента:
<button aria-label="Закрыть окно">
<svg>...</svg>
</button>
<a href="/cart" aria-label="Корзина, 3 товара">
<i class="bi bi-cart"></i>
</a>
Когда: у элемента нет видимого текста.
Правило: обязательно для кнопок с иконками.
aria-labelledby
Ссылка на элемент с меткой:
<section aria-labelledby="features-title">
<h2 id="features-title">Возможности</h2>
<p>...</p>
</section>
Когда: метка — существующий элемент.
Разница:
aria-label— текст прямо в атрибуте.aria-labelledby— ID элемента.
aria-describedby
Описание через ID:
<input type="password" aria-describedby="password-hint">
<p id="password-hint">Минимум 8 символов</p>
Что даёт: скринридер читает подсказку.
Правило: ошибки форм — через aria-describedby.
aria-hidden
Скрыть от скринридеров:
<button aria-label="Закрыть">
<svg aria-hidden="true">...</svg>
</button>
Когда: декоративные элементы, дубли.
Правило: не скрывать фокусируемые элементы.
aria-expanded
Раскрыт ли элемент:
<button aria-expanded="false" aria-controls="menu">
Меню
</button>
<ul id="menu" hidden>...</ul>
JS:
button.addEventListener('click', () => {
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!expanded));
menu.hidden = expanded;
});
Когда: dropdown, аккордеон, burger-меню.
aria-controls
Какой элемент контролирует:
<button aria-controls="panel-1" aria-expanded="true">
Показать
</button>
<div id="panel-1">...</div>
aria-current
Текущий элемент:
<nav>
<ul>
<li><a href="/" aria-current="page">Главная</a></li>
<li><a href="/about">О нас</a></li>
</ul>
</nav>
Значения:
page— текущая страница.step— шаг.location— текущая локация.date— текущая дата.time— текущее время.true— общее.
aria-live
Динамически обновляемый контент:
<div aria-live="polite" id="status"></div>
Значения:
polite— когда удобно.assertive— немедленно.off— не сообщать.
JS:
status.textContent = 'Товар добавлен в корзину';
Когда: toast, счётчики, уведомления, чат.
aria-busy
Элемент загружается:
<div aria-busy="true" aria-live="polite">
Загрузка...
</div>
Что даёт: скринридер ждёт завершения.
aria-invalid
Невалидное поле:
<input
type="email"
aria-invalid="true"
aria-describedby="email-error"
>
<p id="email-error" role="alert">Введите корректный email</p>
Когда: после неудачной валидации.
aria-required
Обязательное поле:
<input type="email" required aria-required="true">
⚠️ required уже делает это в HTML5. aria-required — для кастомных элементов.
Роли для навигации
Меню:
<nav aria-label="Главное меню">
<ul role="list">
<li role="listitem">
<a href="/" aria-current="page">Главная</a>
</li>
</ul>
</nav>
⚠️ <ul> уже list. Не нужно.
Правило: не добавлять роли к нативным элементам.
Accordion (аккордеон)
<h3>
<button
aria-expanded="false"
aria-controls="section1"
id="accordion1id"
>
Заголовок аккордеона
</button>
</h3>
<div
id="section1"
role="region"
aria-labelledby="accordion1id"
hidden
>
Содержимое
</div>
Tabs (вкладки)
<div role="tablist">
<button
role="tab"
id="tab-1"
aria-selected="true"
aria-controls="panel-1"
>
Вкладка 1
</button>
<button
role="tab"
id="tab-2"
aria-selected="false"
aria-controls="panel-2"
tabindex="-1"
>
Вкладка 2
</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
Содержимое 1
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
Содержимое 2
</div>
Клавиши:
- Arrow Left/Right — переключение.
- Home / End — первая / последняя.
Dialog / Modal
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-body"
>
<h2 id="modal-title">Заголовок</h2>
<p id="modal-body">Содержимое</p>
<button aria-label="Закрыть">×</button>
</div>
Что важно:
aria-modal="true"— фокус внутри.aria-labelledby— заголовок.- Фокус-ловушка — Tab не выходит.
- Escape — закрывает.
Правило: лучше использовать нативный <dialog>.
Нативный <dialog>
<dialog id="myModal">
<h2>Заголовок</h2>
<p>Содержимое</p>
<button>Закрыть</button>
</dialog>
JS:
dialog.showModal();
dialog.close();
Что даёт:
- Фокус-ловушка.
- Escape.
- Backdrop.
- Accessibility.
Правило: использовать <dialog> — минимум ARIA.
Практика: доступные кнопки-иконки
<button type="button" aria-label="Удалить">
<i class="bi bi-trash" aria-hidden="true"></i>
</button>
<button type="button" aria-label="Редактировать">
<i class="bi bi-pencil" aria-hidden="true"></i>
</button>
Правило:
- Иконка —
aria-hidden. - Кнопка —
aria-label.
Практика: доступная форма
<form>
<div class="mb-3">
<label for="email">Email</label>
<input
type="email"
id="email"
required
aria-required="true"
aria-describedby="email-error"
aria-invalid="false"
>
<p id="email-error" role="alert"></p>
</div>
<button type="submit">Отправить</button>
</form>
JS:
input.addEventListener('blur', () => {
const valid = input.checkValidity();
input.setAttribute('aria-invalid', String(!valid));
error.textContent = valid ? '' : 'Введите корректный email';
});
Инструменты
Автоматически:
- Lighthouse — Accessibility аудит.
- axe DevTools — расширение.
- WAVE — онлайн.
- Pa11y — CLI.
Вручную:
- Tab — навигация по всем элементам.
- Screen reader — NVDA, VoiceOver.
- Zoom — 200%.
- Windows High Contrast Mode.
Правило: автоматика ловит 30%. Остальное — вручную.
Практика
1. Прогоните сайт через axe DevTools.
2. Пройдите табом — все элементы доступны?
3. Проверьте: кнопки-иконки с aria-label.
4. Модалка через <dialog>.
5. Toast с aria-live.
6. Tabs с ролями.
7. Форма с ошибками через aria-describedby.
Частые ошибки
1. ARIA-роль вместо тега:
<div role="button">
Решение: <button>.
2. aria-hidden на фокусируемом:
<button aria-hidden="true">
Решение: убрать или заменить.
3. aria-label совпадает с текстом:
<button aria-label="Сохранить">Сохранить</button>
Избыточно.
4. Нет aria-current:
Непонятно, где мы
Решение: добавить.
5. Кнопка без type:
<button>Отправить</button>
По умолчанию submit. В форме — type="button".
6. role="alert" без aria-live:
Не всегда работает
Решение: role="alert" уже assertive. Или aria-live="assertive".
7. Модалка без фокус-ловушки:
Tab уходит за пределы
Решение: <dialog> или focus trap.
8. Забыть aria-expanded:
Скринридер не знает состояние
Решение: обновлять через JS.
Итог
- Правило №1: не ARIA, если есть тег.
aria-label— метка.aria-labelledby— метка из элемента.aria-describedby— описание.aria-hidden— скрыть от скринридера.aria-expanded— раскрыто.aria-current— текущий.aria-live— динамика.aria-invalid— ошибка.<dialog>— для модалок.- Lighthouse + axe — проверка.
- Tab + screen reader — вручную.
Дальше — тёмная тема: реализация.