Фокус и клавиатурная навигация

Клавиатурная навигация -- способ управления сайтом без мыши. Фокус (focus) показывает, какой элемент сейчас активен. Для пользователей с моторными нарушениями, screen reader-ов и опытных пользователей клавиатура -- основной инструмент.

Зачем нужно

Около 25% пользователей не используют мышь: незрячие с screen reader, люди с тремором, продвинутые пользователи с клавиатурой. Если элемент нельзя активировать с клавиатуры -- он недоступен. WCAG 2.1 Level A требует полную клавиатурную доступность.

Где используется

  • Навигация по сайту (Tab / Shift+Tab)
  • Модальные окна (focus trap)
  • Кастомные компоненты (табы, дропдауны, меню)
  • SPA-приложения (управление фокусом при навигации)

Предпосылки

Нативно фокусируемые элементы

Браузер автоматически делает фокусируемыми:

<!-- Эти элементы фокусируемы по умолчанию -->
<a href="/page">Ссылка</a>
<button>Кнопка</button>
<input type="text">
<textarea></textarea>
<select><option>...</option></select>
<details><summary>...</summary></details>

<!-- Эти -- НЕ фокусируемы -->
<div>Блок</div>
<span>Текст</span>
<p>Параграф</p>
<section>Секция</section>

tabindex -- управление фокусом

<!-- tabindex="0": элемент в естественном порядке Tab -->
<div role="button" tabindex="0">Кнопка на div</div>

<!-- tabindex="-1": фокусируемый программно, но НЕ через Tab -->
<div id="modal-content" tabindex="-1">
  <!-- Можно перевести фокус через JS, но Tab не попадёт сюда -->
</div>

<!-- tabindex="1+" (НИКОГДА): меняет порядок Tab -->
<!-- НЕ ДЕЛАЙ ТАК -->
<input tabindex="3">
<input tabindex="1">
<input tabindex="2">
<!-- Порядок Tab: 1 → 2 → 3, а не сверху вниз. Кошмар для пользователя -->

Правило tabindex

tabindex="0"  → Элемент в Tab-порядке (используй)
tabindex="-1" → Программный фокус, не Tab (используй)
tabindex="1+" → Кастомный порядок (НИКОГДА)

Focus order -- порядок фокуса

Порядок Tab = порядок элементов в DOM. Визуальная перестановка через CSS не меняет Tab-порядок:

<!-- DOM order = Tab order -->
<button>Первая</button>    <!-- Tab 1 -->
<button>Вторая</button>    <!-- Tab 2 -->
<button>Третья</button>    <!-- Tab 3 -->
/* CSS flexbox/grid может визуально переставить, но Tab-порядок останется DOM */
.container {
  display: flex;
  flex-direction: row-reverse;
}
/* Визуально: Третья → Вторая → Первая */
/* Tab-порядок: Первая → Вторая → Третья (по DOM!) */

Решение: меняй порядок в HTML, а не в CSS, если Tab-порядок важен.

:focus и :focus-visible

/* :focus -- ЛЮБОЙ фокус (клавиатура, мышь, JS) */
button:focus {
  outline: 2px solid blue;
}

/* :focus-visible -- только клавиатурный фокус */
/* Браузер сам определяет: Tab = показать, клик = скрыть */
button:focus-visible {
  outline: 3px solid #4A90D9;
  outline-offset: 2px;
}

/* Убрать outline только для мыши, сохранить для клавиатуры */
button:focus:not(:focus-visible) {
  outline: none;
}

Стилизация фокуса

/* Минимальные требования WCAG: контрастный, заметный outline */
:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

/* Кастомный стиль фокуса */
.custom-focus:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px #fff, 0 0 0 5px #005fcc;
  border-radius: 4px;
}

/* НИКОГДА не убирай outline без замены */
/* Плохо: */
*:focus { outline: none; }  /* Пользователь потерял фокус! */

/* Хорошо: замени на свой стиль */
*:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

Focus trap -- ловушка фокуса

При открытии модалки фокус должен оставаться внутри неё:

<dialog id="modal" aria-labelledby="modal-title">
  <h2 id="modal-title">Подтверждение</h2>
  <p>Вы уверены?</p>
  <button id="cancel-btn">Отмена</button>
  <button id="confirm-btn">Подтвердить</button>
</dialog>
const modal = document.getElementById('modal');
const cancelBtn = document.getElementById('cancel-btn');
const confirmBtn = document.getElementById('confirm-btn');

function openModal() {
  modal.showModal; // <dialog> автоматически создаёт focus trap!
  cancelBtn.focus();
}

// Для кастомных модалок (не <dialog>) -- ручной focus trap:
function trapFocus(container) {
  const focusable = container.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  container.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;

    if (e.shiftKey) {
      // Shift+Tab на первом → перейти на последний
      if (document.activeElement === first) {
        e.preventDefault();
        last.focus();
      }
    } else {
      // Tab на последнем → перейти на первый
      if (document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
  });
}

Управление фокусом в SPA

При навигации в SPA страница не перезагружается, и фокус остаётся на ссылке:

// При переходе на новую "страницу" -- переместить фокус
function navigateTo(route) {
  renderPage(route);

  // Переместить фокус на заголовок новой страницы
  const heading = document.querySelector('h1');
  heading.setAttribute('tabindex', '-1');
  heading.focus();

  // Обновить title
  document.title = `${heading.textContent} | My App`;
}

// Или использовать aria-live для объявления
const announcer = document.getElementById('route-announcer');
function announceRoute(pageName) {
  announcer.textContent = `Перешли на страницу: ${pageName}`;
}
<!-- Скрытый announcer для навигации -->
<div id="route-announcer"
     role="status"
     aria-live="assertive"
     class="visually-hidden">
</div>

Клавиатурные паттерны для компонентов

Табы (WAI-ARIA pattern)

<div role="tablist">
  <button role="tab" aria-selected="true" id="tab1">Вкладка 1</button>
  <button role="tab" aria-selected="false" id="tab2" tabindex="-1">Вкладка 2</button>
  <button role="tab" aria-selected="false" id="tab3" tabindex="-1">Вкладка 3</button>
</div>
// Roving tabindex: Tab заходит в группу, стрелки перемещают внутри
const tabs = document.querySelectorAll('[role="tab"]');

tabs.forEach((tab, index) => {
  tab.addEventListener('keydown', (e) => {
    let newIndex;
    switch (e.key) {
      case 'ArrowRight':
        newIndex = (index + 1) % tabs.length;
        break;
      case 'ArrowLeft':
        newIndex = (index - 1 + tabs.length) % tabs.length;
        break;
      case 'Home':
        newIndex = 0;
        break;
      case 'End':
        newIndex = tabs.length - 1;
        break;
      default:
        return;
    }
    e.preventDefault();
    tabs[index].setAttribute('tabindex', '-1');
    tabs[newIndex].setAttribute('tabindex', '0');
    tabs[newIndex].focus();
    activateTab(tabs[newIndex]);
  });
});

Кнопка на div

<div role="button" tabindex="0" id="custom-btn">Действие</div>
const btn = document.getElementById('custom-btn');

btn.addEventListener('keydown', (e) => {
  // Enter и Space должны активировать кнопку
  if (e.key === 'Enter' || e.key === ' ') {
    e.preventDefault();
    btn.click();
  }
});

inert -- отключение части DOM

<!-- inert скрывает элемент от Tab и screen reader -->
<main inert>
  <!-- Всё содержимое недоступно пока модалка открыта -->
</main>

<dialog open>
  <h2>Модальное окно</h2>
  <button>Закрыть</button>
</dialog>

<script>
  function openDialog() {
    document.querySelector('main').inert = true;
    document.querySelector('dialog').showModal;
  }

  function closeDialog() {
    document.querySelector('main').inert = false;
    document.querySelector('dialog').close();
  }
</script>

Частые ошибки

Ошибка Проблема Решение
outline: none без замены Фокус невидим :focus-visible с кастомным стилем
tabindex > 0 Неестественный порядок Tab Изменить порядок в DOM
<div onclick> без tabindex и key handler Недоступно с клавиатуры <button> или tabindex="0" + key
Нет focus trap в модалке Tab уходит за модалку <dialog> или ручной trap
CSS order / flex-direction без учёта Tab Визуальный и Tab-порядок расходятся Менять порядок в HTML
Автофокус на каждой странице Раздражает, пропускает контент Только если фокус помогает

Практика

  1. Пройди свой сайт только клавиатурой (Tab, Shift+Tab, Enter, Escape)
  2. Добавь :focus-visible стили ко всем интерактивным элементам
  3. Реализуй focus trap для кастомной модалки
  4. Создай табы с roving tabindex (стрелки для переключения)
  5. Проверь порядок Tab на странице с flex/grid layout

Связанные темы

Ресурсы