Skip to main content

Доступність вебу та атрибути ARIA

Доступність вебу (a11y) - це підхід до розробки сайтів, які працюють для людей з порушеннями зору, слуху та руху: для тих, хто користується екранними читачами або навігацією з клавіатури. ARIA-атрибути - це інструмент для випадків, коли семантичного HTML недостатньо.

Теорія

TL;DR

  • ARIA схожа на шрифт Брайля на музейних експонатах: сам експонат (HTML) вже описує себе, але Брайль заповнює прогалини для тих, кому він потрібен
  • Семантичні HTML-елементи (<button>, <nav>, <main>) автоматично повідомляють екранним читачам, що вони собою являють; ARIA робить те ж саме для звичайних <div> і кастомних компонентів
  • Правило: використовуй ARIA лише тоді, коли в HTML немає відповідного нативного елемента для того, що ти будуєш
  • Динамічні сповіщення, модальні вікна і слайдери майже завжди потребують ARIA; статичний контент - зазвичай ні

Швидкий приклад

html
<!-- Нативний 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" на нативній кнопці:

html
<button role="button">Клікни</button>

Шкоди немає, але екранні читачі ігнорують дублювання ролі. Видали атрибут і покладайся на нативну семантику.

aria-label поруч із видимим <label>:

html
<label>Логін: <input aria-label="Поле логін" /></label> <!-- Екранний читач скаже: "Поле логін, поле введення" — дві мітки конфліктують -->

Дозволь елементу <label> виконати свою роботу. Прибери aria-label, якщо правильна асоціація вже є.

Забутий aria-live при динамічних змінах:

jsx
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
<!-- Статична навігація: семантичний 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

jsx
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-слайдер з підтримкою клавіатури

html
<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.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?