Коротка відповідь. Доступність перевіряється не плагіном, а порядком дій: 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

Короткий чекліст, який реально приживається в команді:

  1. Нові інтерактивні елементи досяжні з клавіатури, фокус видимий.
  2. Модалки/меню: trap + Escape + повернення фокуса.
  3. Інпути мають label; помилки – aria-describedby + summary.
  4. Семантичні теги замість div-ів з обробниками; Fragment Refs замість зайвих обгорток, якщо div був «технічний».
  5. axe у dev-консолі – нуль нових порушень.

П'ять пунктів, три хвилини рев'ю – і доступність перестає бути «великим проєктом на потім».

Типові помилки

  • outline: none без заміни – найпоширеніший злочин проти клавіатури.
  • aria-label на все підряд – зайвий ARIA створює шум і конфлікти з видимим текстом.
  • Перевірка «скрінрідером» один раз на реліз – замість клавіатурного проходу в кожному PR.
  • Токен-контраст «на око» – контраст міряється інструментом (4.5:1 для звичайного тексту).
  • Автофокус на модалку з «корисною» рекламою – технічно доступно, людяно – ні.

Практичне завдання

Проведи цей 60-хвилинний аудит на власному проєкті (або pet-проєкті з портфоліо – на співбесідах доступність усе частіше питають і в Middle→Senior контексті). Зафіксуй знахідки трьома списками: «блокує користувача», «заважає», «косметика». Виправ перший список цього ж тижня – зазвичай це 3-5 правок по годині сумарно, а різниця для реальних людей – кардинальна.