Доступність вебу та атрибути ARIA
Доступність вебу (a11y) - це підхід до розробки сайтів, які працюють для людей з порушеннями зору, слуху та руху: для тих, хто користується екранними читачами або навігацією з клавіатури. ARIA-атрибути - це інструмент для випадків, коли семантичного HTML недостатньо.
Теорія
TL;DR
- ARIA схожа на шрифт Брайля на музейних експонатах: сам експонат (HTML) вже описує себе, але Брайль заповнює прогалини для тих, кому він потрібен
- Семантичні HTML-елементи (
<button>,<nav>,<main>) автоматично повідомляють екранним читачам, що вони собою являють; ARIA робить те ж саме для звичайних<div>і кастомних компонентів - Правило: використовуй ARIA лише тоді, коли в HTML немає відповідного нативного елемента для того, що ти будуєш
- Динамічні сповіщення, модальні вікна і слайдери майже завжди потребують ARIA; статичний контент - зазвичай ні
Швидкий приклад
<!-- Нативний HTML: екранний читач скаже "Відправити, кнопка" без жодного зайвого коду -->
<button>Відправити</button>
<!-- ARIA заповнює прогалину для динамічного контенту, доданого через JS -->
<div role="alert" aria-live="polite">Форму відправлено!</div>
<!-- Екранний читач озвучить це одразу, навіть коли JS додає елемент у DOM -->
<!-- Кастомна модалка: у HTML немає нативного аналога -->
<div role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">Підтвердити дію</h2>
<button>Закрити</button>
</div>Перший приклад не потребує нічого. Два наступних показують, де ARIA справді потрібна.
Головна різниця
Семантичні HTML-елементи відображаються безпосередньо у вузли Accessibility Tree в браузері. <button> стає вузлом з role="button", обчисленим ім'ям із текстового вмісту і вбудованою підтримкою клавіатури - без зайвого коду. ARIA-атрибути на зразок role="button" або aria-expanded записують у те ж дерево вручну, але поведінку не додають. <div role="button"> без tabindex="0" не отримує фокус. Без обробника keydown Enter і пробіл нічого не роблять. Нативний HTML робить усе це автоматично.
Коли використовувати ARIA
- Статичні сторінки з формами, посиланнями та навігацією: тільки семантичний HTML
- Повідомлення про помилки або статус, які додає JS:
role="alert"абоaria-live="polite"на контейнері - Модальні вікна:
role="dialog"+aria-modal="true"+aria-labelledbyз посиланням на заголовок - Кастомні компоненти без HTML-аналогів (слайдери, деревоподібні списки, вкладки): ARIA-ролі в поєднанні з обробниками клавіатурних подій
- Декоративні іконки всередині підписаних кнопок:
aria-hidden="true"щоб уникнути подвійного зачитування - Пропускай ARIA, якщо нативний семантичний тег вже покриває сценарій
Як працює Accessibility Tree
Браузер парсить HTML і будує Accessibility Tree поряд із DOM. Екранні читачі звертаються до нього через API операційної системи: Microsoft UI Automation на Windows, Apple Accessibility API на macOS і iOS. Елемент <button> отримує вузол з роллю, ім'ям і станом, заповненими автоматично. ARIA-атрибути перевизначають або доповнюють ці властивості під час рендерингу - Blink і WebKit вставляють aria-expanded або aria-valuenow безпосередньо в дерево.
Live-регіони (aria-live, role="alert") працюють інакше. Коли JS змінює вміст live-регіону, браузер одразу надсилає сповіщення про diff екранному читачу - не чекаючи, поки користувач перейде до цього місця. Саме тому зміна тексту в звичайному <div> не озвучується, а та сама зміна всередині <div aria-live="polite"> - озвучується.
Типові помилки
role="button" на нативній кнопці:
<button role="button">Клікни</button>Шкоди немає, але екранні читачі ігнорують дублювання ролі. Видали атрибут і покладайся на нативну семантику.
aria-label поруч із видимим <label>:
<label>Логін: <input aria-label="Поле логін" /></label>
<!-- Екранний читач скаже: "Поле логін, поле введення" — дві мітки конфліктують -->Дозволь елементу <label> виконати свою роботу. Прибери aria-label, якщо правильна асоціація вже є.
Забутий aria-live при динамічних змінах:
setError('Невірний email');
// <div id="error">{error}</div> — для екранного читача це тишаРішення: додай role="alert" або aria-live="assertive" на контейнер до того, як у ньому з'явиться текст помилки.
aria-expanded без синхронізації в JS:
Атрибут оголошує стан розкриття. Якщо JS ніколи не змінює його з false на true, читач повідомить користувача, що меню відкрилось - хоча це не так. Синхронізуй атрибут при кожній взаємодії.
Де зустрічається
- React: Material UI tabs використовують
role="tablist"зaria-selectedна кожному таб-елементі - Next.js:
aria-current="page"на активному<Link>для навігаційних landmark-ів - Vue: Vuetify data tables додають
aria-sortдо заголовків колонок із сортуванням - Angular CDK: оверлеї застосовують
aria-labelчерезng-attr-aria-label - Тестування: панель Accessibility в Chrome DevTools показує обчислене дерево; axe-core автоматично знаходить відсутні мітки і неправильні ролі
Питання на співбесіді
Q: Яка різниця між role="alert" і aria-live="polite"?
A: role="alert" автоматично встановлює aria-live="assertive" і перериває поточне зачитування. Використовуй для повідомлень про помилки. aria-live="polite" ставить оголошення в чергу і озвучує після завершення поточного речення - підходить для ненагальних статусних повідомлень.
Q: Чим aria-hidden="true" відрізняється від display: none?
A: aria-hidden прибирає елемент з Accessibility Tree, але залишає його видимим у DOM. Корисно для декоративних іконок і анімацій. display: none прибирає елемент і з візуального макету, і з дерева доступності.
Q: Чому tabindex="0" на <div> - це проблема?
A: Він робить елемент фокусованим, але не дає йому інтерактивної ролі, поведінки клавіатури або ARIA-стану. Для клавіатурних користувачів Tab потрапляє в "глухий кут". Додавай tabindex="0" тільки для повноцінного кастомного компонента з усіма потрібними обробниками клавіш.
Q: Поясни WCAG 4.1.2 Name, Role, Value.
A: Допоміжна технологія має отримати від кожного інтерактивного елемента три речі: ім'я (з текстового вмісту, aria-label або aria-labelledby), роль (з HTML-тегу або атрибута role) і поточний стан або значення (aria-valuenow, aria-checked, aria-expanded). ARIA існує саме для того, щоб забезпечити це там, де HTML не справляється.
Q: (Senior) Як працює focus trap у модалці на React-порталі, і що може піти не так при SSR-гідрації?
A: Встановлюй aria-modal="true", щоб екранний читач сприймав діалог як єдину активну область, і додавай focus trap, що перехоплює Tab і Shift+Tab. При розбіжності гідрації портал може не існувати в початковому HTML з сервера, і модалка монтується без фокусу. Рішення: виклик containerRef.current.focus() всередині useEffect після монтування і атрибут inert на фоновому контенті. VoiceOver в Safari має відому проблему з виходом фокусу з порталів - завжди тестуй цю комбінацію окремо.
Приклади
Семантичний HTML проти ARIA-ролей
<!-- Статична навігація: семантичний HTML, ARIA не потрібна -->
<nav>
<a href="/" aria-current="page">Головна</a>
<a href="/about">Про нас</a>
</nav>
<!-- Динамічна помилка: потрібен ARIA live-регіон -->
<form>
<label for="email">Email</label>
<input type="email" id="email" aria-describedby="email-error" />
<!-- role="alert" озвучує помилку одразу при появі тексту -->
<div id="email-error" role="alert" aria-live="assertive"></div>
</form>
<!-- Декоративна іконка всередині підписаної кнопки -->
<button aria-label="Закрити діалог">
<svg aria-hidden="true" focusable="false">...</svg>
</button>У прикладі з навігацією aria-current="page" на активному посиланні - це ARIA, але правильний вибір, бо в HTML немає нативного поняття "поточна сторінка". Все інше - звичайний семантичний HTML.
Доступна модалка в React
import { useState, useEffect, useRef } from 'react';
function Modal() {
const [isOpen, setIsOpen] = useState(false);
const modalRef = useRef(null);
useEffect(() => {
// Переміщуємо фокус у модалку при відкритті
if (isOpen) modalRef.current?.focus();
}, [isOpen]);
return (
<>
<button onClick={() => setIsOpen(true)}>Видалити акаунт</button>
{isOpen && (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
style={{ position: 'fixed', background: 'white', padding: 24 }}
>
<h2 id="modal-title">Ти впевнений?</h2>
<p>Цю дію не можна скасувати.</p>
<button onClick={() => setIsOpen(false)}>Скасувати</button>
<button>Підтвердити</button>
</div>
)}
</>
);
}
// NVDA оголосить "dialog Ти впевнений?" при відкритті модалки.
// aria-modal каже читачу ігнорувати контент поза діалогом.У продакшені я завжди додаю обробник клавіші Escape для закриття і focus trap, що тримає Tab всередині діалогу. Одного ARIA для повноцінної клавіатурної доступності недостатньо.
Кастомний ARIA-слайдер з підтримкою клавіатури
<div
role="slider"
tabindex="0"
aria-valuenow="50"
aria-valuemin="0"
aria-valuemax="100"
aria-label="Гучність"
style="width: 200px; height: 20px; background: #ddd;"
></div>
<script>
const slider = document.querySelector('[role="slider"]');
slider.addEventListener('keydown', (e) => {
let val = +slider.getAttribute('aria-valuenow');
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault(); // зупиняє прокрутку сторінки
slider.setAttribute('aria-valuenow', Math.min(val + 10, 100));
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
e.preventDefault();
slider.setAttribute('aria-valuenow', Math.max(val - 10, 0));
}
});
</script>Екранний читач озвучує нове значення щоразу, коли змінюється aria-valuenow. Без e.preventDefault() стрілки одночасно прокручують сторінку і рухають слайдер - баг, який легко пропустити при візуальному тестуванні, але одразу помітний у NVDA.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.