Коротка відповідь. Доступність перевіряється не плагіном, а порядком дій: 15 хвилин проходу клавіатурою без мишки → 10 хвилин на поведінку фокуса в модалках і меню → 10 хвилин на семантику (HTML перед ARIA) → 15 хвилин на форми й помилки → 10 хвилин автоматики (axe) наприкінці, а не замість рук. Нижче – цей маршрут по кроках, код accessible модалки за APG-патерном, error summary для форм і definition of done, щоб результат не розсипався через місяць.
Хвилини 0–15: keyboard-only прохід
Сховай мишку. Серйозно – поклади її за монітор. Далі тільки Tab, Shift+Tab, Enter, Space, Escape і стрілки:
- Усе інтерактивне досяжне? Якщо до елемента не можна дотабатись – для частини користувачів його не існує.
- Фокус видимий? На кожному елементі має бути помітний індикатор. Якщо дизайнеру не подобається дефолтний – стилізуйте
:focus-visible, а неoutline: none. - Порядок логічний? Фокус має йти за змістом, а не стрибати по DOM-акробатиці. Якщо порядок дивний – зазвичай проблема в розмітці, а не в tabindex (додатні tabindex – майже завжди помилка).
- Нічого не ловить фокус у пастку? Віджети-каруселі й чати люблять «з'їсти» Tab назавжди.
Уже цей крок знаходить більшість реальних проблем – і він безкоштовний.
Хвилини 15–25: фокус у діалогах і меню
Модалка за APG-патерном має чотири обов'язки: фокус заходить у діалог при відкритті, не виходить за його межі (trap), Escape закриває, після закриття фокус повертається на елемент-тригер. Мінімальна реалізація без бібліотек:
function Dialog({ open, onClose, title, children }: DialogProps) {
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const trigger = document.activeElement as HTMLElement | null;
panelRef.current?.querySelector<HTMLElement>('[data-autofocus]')?.focus();
return () => trigger?.focus(); // повернення фокуса тригеру
}, [open]);
if (!open) return null;
function onKeyDown(event: React.KeyboardEvent) {
if (event.key === 'Escape') onClose();
if (event.key !== 'Tab' || !panelRef.current) return;
// простий trap: тримаємо Tab у межах діалога
const focusables = panelRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault(); last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault(); first.focus();
}
}
return (
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
onKeyDown={onKeyDown}
>
<h2 id="dialog-title">{title}</h2>
{children}
<button data-autofocus onClick={onClose}>Закрити</button>
</div>
);
}Для меню/лістбоксів правило інше: коли native, а коли ARIA. Випадний список значень – це <select>; він безкоштовно доступний на всіх платформах. ARIA-патерн (role="menu", roving tabindex, стрілки) потрібен, лише коли дизайн-вимоги справді не влазять у нативний елемент – і тоді реалізуй патерн повністю, половина ARIA гірша за її відсутність.
Хвилини 25–35: семантика перед ARIA
Перше правило ARIA за MDN – не використовувати ARIA там, де є нативний HTML:
<div onClick>→<button>. Безкоштовно отримуєш фокус, Enter/Space, роль.- Ієрархія заголовків без пропусків: h1 → h2 → h3; скрінрідер-користувачі навігують по заголовках частіше, ніж ти Tab-ом.
- Посилання ведуть кудись (
<a href>), кнопки щось роблять. «Посилання», що відкриває модалку – кнопка. - Лендмарки:
<main>,<nav aria-label>,<header>– навігаційна карта сторінки. aria-*– останній інструмент: коли семантики HTML об'єктивно бракує (кастомний таб-бар, live-region).
Швидка перевірка: відкрий дерево доступності в девтулзах – якщо там «generic generic generic text», семантики немає.
Хвилини 35–50: форми й помилки
Найбільше реального болю – тут:
- Кожен інпут має
<label>(видимий; placeholder – не label). - Помилка привʼязана до поля через
aria-describedby, а поле позначенеaria-invalid. - Після невдалого сабміту фокус і увага йдуть до error summary – блоку зверху форми, який скрінрідер оголосить:
function ErrorSummary({ errors }: { errors: FormError[] }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (errors.length > 0) ref.current?.focus();
}, [errors]);
if (errors.length === 0) return null;
return (
<div ref={ref} tabIndex={-1} role="alert" aria-labelledby="error-summary-title">
<h2 id="error-summary-title">Форму не відправлено: {errors.length} помилки</h2>
<ul>
{errors.map((error) => (
<li key={error.fieldId}>
<a href={`#${error.fieldId}`}>{error.message}</a>
</li>
))}
</ul>
</div>
);
}Посилання в summary ведуть до полів – користувач клавіатури виправляє помилки по черзі, не шукаючи їх по формі.
Хвилини 50–60: автоматика
axe (DevTools-розширення або @axe-core/react у dev-режимі) ловить контраст, відсутні label-и, дублікати id, зламані ARIA-посилання. Це приблизно третина класів проблем – цінна третина, бо знаходиться за секунди. Але axe не бачить: нелогічний порядок фокуса, пастки, безглузді alt-тексти, модалку без повернення фокуса. Тому автоматика – останній крок аудиту, а не його заміна. Якщо компонентні тести вже написані через ролі й доступні імена – половина регресій доступності ловиться безкоштовно.
Definition of done для PR
Короткий чекліст, який реально приживається в команді:
- Нові інтерактивні елементи досяжні з клавіатури, фокус видимий.
- Модалки/меню: trap + Escape + повернення фокуса.
- Інпути мають label; помилки –
aria-describedby+ summary. - Семантичні теги замість div-ів з обробниками; Fragment Refs замість зайвих обгорток, якщо div був «технічний».
- axe у dev-консолі – нуль нових порушень.
П'ять пунктів, три хвилини рев'ю – і доступність перестає бути «великим проєктом на потім».
Типові помилки
outline: noneбез заміни – найпоширеніший злочин проти клавіатури.aria-labelна все підряд – зайвий ARIA створює шум і конфлікти з видимим текстом.- Перевірка «скрінрідером» один раз на реліз – замість клавіатурного проходу в кожному PR.
- Токен-контраст «на око» – контраст міряється інструментом (4.5:1 для звичайного тексту).
- Автофокус на модалку з «корисною» рекламою – технічно доступно, людяно – ні.
Практичне завдання
Проведи цей 60-хвилинний аудит на власному проєкті (або pet-проєкті з портфоліо – на співбесідах доступність усе частіше питають і в Middle→Senior контексті). Зафіксуй знахідки трьома списками: «блокує користувача», «заважає», «косметика». Виправ перший список цього ж тижня – зазвичай це 3-5 правок по годині сумарно, а різниця для реальних людей – кардинальна.