Фокус и клавиатурная навигация
Клавиатурная навигация -- способ управления сайтом без мыши. Фокус (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 |
| Автофокус на каждой странице | Раздражает, пропускает контент | Только если фокус помогает |
Практика
- Пройди свой сайт только клавиатурой (Tab, Shift+Tab, Enter, Escape)
- Добавь
:focus-visibleстили ко всем интерактивным элементам - Реализуй focus trap для кастомной модалки
- Создай табы с roving tabindex (стрелки для переключения)
- Проверь порядок Tab на странице с flex/grid layout
Связанные темы
- ARIA атрибуты -- роли и состояния для SR
- Семантика для screen readers -- как SR воспринимает фокус
- dialog -- нативный focus trap
- Интерактивные элементы -- нативные интерактивные элементы
- Формы в HTML -- фокус в формах