Фокус: видимость и порядок
Фокус — это курсор клавиатуры. Если его не видно или он ходит не туда, сайтом просто нельзя пользоваться без мыши.
Фокус (focus) — элемент страницы, который прямо сейчас принимает ввод с клавиатуры: Enter, Space, стрелки, текст. Он всегда ровно один, и пользователь обязан видеть, где он находится.
Зачем это нужно на практике
Мышь — не единственный способ управлять страницей, и далеко не для всех он рабочий. Клавиатурой (или устройством, которое эмулирует клавиатуру) пользуются люди с тремором и другими моторными нарушениями, для которых попасть курсором в кнопку 24×24 пикселя — испытание. Незрячие: скринридер ходит ровно по тем же фокусируемым элементам, что и Tab. Люди с временной травмой — сломанная рука лечится месяц, а сайт нужен сегодня. И, наконец, обычные опытные пользователи, которые заполняют форму быстрее вас, потому что не трогают мышь.
Проблема в том, что фокус — единственная часть интерфейса, которую фронтендер видит реже всех. Мы разрабатываем мышью, тестируем мышью и не замечаем, что кольцо фокуса кто-то отключил в CSS-ресете три года назад. Пользователь замечает мгновенно: он жмёт Tab и не понимает, где он. Это как убрать курсор мыши — технически всё кликается, практически невозможно.
В WCAG (Web Content Accessibility Guidelines — международный стандарт доступности) это сразу несколько требований: 2.1.1 Keyboard — вся функциональность доступна с клавиатуры; 2.4.3 Focus Order — порядок обхода осмысленный; 2.4.7 Focus Visible — фокус видно; 2.4.11 Focus Not Obscured (из WCAG 2.2) — сфокусированный элемент не спрятан под липкой шапкой или плашкой кук.
Почему outline: none — это вредительство
Самая распространённая строчка, убивающая доступность целого сайта, выглядит так:
/* ❌ Классика жанра. Встречается в каждом втором ресете */
*:focus {
outline: none;
}
Дизайнер попросил убрать «эту синюю рамку», потому что она некрасивая. Разработчик убрал — и сайт для клавиатурного пользователя превратился в тёмную комнату: фокус двигается, но где он, знает только браузер. Нажимаешь Enter — и что-то происходит. Что именно — сюрприз.
Убирать индикатор можно только в одном случае: если вы тут же рисуете свой, заметнее стандартного. Не «тоньше и бледнее, чтобы не мешало», а именно заметнее.
:focus-visible — тот компромисс, которого все ждали
Исторический корень спора: :focus срабатывает и при клике мышью тоже. Кликнул по кнопке — получил рамку, которая тебе не нужна, и она висит, пока не кликнешь в другое место. Отсюда и желание всё это выключить.
Псевдокласс :focus-visible решает проблему в лоб: браузер сам решает, нужно ли показывать индикатор. Пришёл фокус с клавиатуры — показывает. Пришёл кликом мыши по кнопке — не показывает. При этом в текстовое поле индикатор ставится всегда (там курсор нужен независимо от способа входа).
/* ✅ Рабочий рецепт */
/* 1. Запасной вариант для старых движков: кольцо есть всегда */
:focus {
outline: 3px solid #1a73e8;
outline-offset: 2px;
}
/* 2. Если браузер умеет :focus-visible — убираем кольцо при клике мышью */
:focus:not(:focus-visible) {
outline: none;
}
/* 3. И рисуем заметное кольцо для клавиатуры */
:focus-visible {
outline: 3px solid #1a73e8;
outline-offset: 2px;
border-radius: 4px;
}
Требования к самому кольцу простые и проверяемые:
- Контраст не ниже 3:1 с фоном, от которого оно отделяется. Светло-серая рамка на белом фоне — это отсутствие рамки.
- Толщина от 2 пикселей. Однопиксельная линия теряется на любом экране.
- Отступ (
outline-offset) — кольцо не должно сливаться с границей кнопки. - Кольцо видно целиком: элемент не обрезан
overflow: hiddenи не спрятан под липкой шапкой.
Отдельно про box-shadow: им тоже часто рисуют фокус, и выглядит это красиво. Но в режиме высокой контрастности Windows (forced-colors) тени просто не рендерятся — и индикатор исчезает у тех, кому он нужнее всего. Если очень хочется тень, добавьте страховку: прозрачный outline в forced-colors становится видимым.
.btn:focus-visible {
box-shadow: 0 0 0 3px #1a73e8;
/* страховка для режима высокой контрастности */
outline: 2px solid transparent;
outline-offset: 2px;
}
Порядок обхода задаёт DOM, а не CSS
Второй сюрприз для многих: последовательность, в которой Tab перебирает элементы, определяется порядком в HTML-документе. CSS может как угодно перетасовать элементы визуально — на порядок фокуса это не влияет.
А значит, любой из этих приёмов создаёт рассинхрон между тем, что человек видит, и тем, куда идёт фокус:
orderиflex-direction: row-reverseво flex-контейнере;grid-areaи явные координаты в grid;position: absolute, которым блок «переставили» в другой угол;- вёрстка, где мобильное меню лежит в конце разметки, а показывается сверху.
Выглядит это так: пользователь видит форму «Имя → Email → Отправить», жмёт Tab и попадает из «Имя» куда-то в подвал, потому что «Email» в разметке лежит после футера, а CSS его поднял. Формально всё работает. Практически — нет.
Правило: визуальный порядок должен совпадать с DOM-порядком. Если для нужной картинки приходится переставлять элементы через CSS — почти всегда правильнее переставить их в HTML.
Что выпадает из обхода, а что только притворяется
| Способ спрятать элемент | Убирает из порядка фокуса? |
display: none | Да |
visibility: hidden | Да |
атрибут hidden | Да |
атрибут inert (на предке) | Да, всё поддерево |
disabled на контроле формы | Да |
opacity: 0 | Нет — фокус туда уйдёт |
height: 0; overflow: hidden | Нет — фокус туда уйдёт |
элемент за пределами экрана (left: -9999px) | Нет — фокус уедет «в никуда» |
aria-hidden="true" | Нет — скринридер молчит, а Tab заходит |
Нижняя половина таблицы — источник классического бага: закрытое выпадающее меню сделано через opacity: 0, и клавиатурный пользователь после кнопки «Меню» проваливается в десяток невидимых ссылок. Никакой обратной связи, просто фокус пропал на пять нажатий Tab.
tabindex: три значения, из которых полезны два
| Значение | Что делает | Когда применять |
tabindex="0" | Элемент встаёт в естественный порядок обхода — там, где он лежит в DOM | Кастомный виджет на <div>, которому нельзя дать нативный тег. Редкий случай |
tabindex="-1" | Элемент нельзя достать по Tab, но можно сфокусировать из кода: el.focus() | Цель ссылки «Пропустить к содержимому», заголовок открывшейся модалки, неактивные вкладки в roving tabindex |
tabindex="1" и выше | Вырывает элемент в начало обхода всей страницы | Никогда |
Почему положительные значения — зло? Потому что они создают отдельную «очередь vip» перед всем остальным документом. Как только на странице появляется tabindex="3", любой новый компонент с tabindex="0" уезжает в конец обхода — и чтобы починить порядок, придётся расставить номера всем элементам страницы. В проекте с несколькими командами это заканчивается войной за tabindex="1". Порядок фокуса — задача разметки, а не нумерации.
И главное: tabindex="0" нужен куда реже, чем кажется. Он даёт только фокусируемость. Роль, обработку Enter и Space, состояние — не даёт.
<!-- ❌ Не кнопка: без фокуса, без Enter, без роли -->
<div class="btn" onclick="save()">Сохранить</div>
<!-- 🤔 Уже фокусируется, но Enter и Space всё ещё надо писать руками -->
<div class="btn" role="button" tabindex="0" onclick="save()">Сохранить</div>
<!-- ✅ Всё бесплатно: фокус, Enter, Space, роль, состояние disabled -->
<button type="button" class="btn">Сохранить</button>
Ссылка «Пропустить к содержимому»
Первый фокусируемый элемент страницы, обычно скрытый до фокуса. Он спасает пользователя от 30 нажатий Tab по меню на каждой странице сайта. Цель ссылки — <main> — по умолчанию не фокусируется, поэтому ей нужен tabindex="-1":
<a class="skip-link" href="#main">Пропустить к содержимому</a>
<header>…длинная навигация…</header>
<main id="main" tabindex="-1">
<h1>Каталог задач</h1>
</main>
Без tabindex="-1" браузер прокрутит страницу к якорю, но фокус останется на ссылке — и следующий Tab снова уведёт в меню.
Как это работает
Браузер строит последовательность фокуса (sequential focus navigation order) по простому алгоритму:
- Сначала — все элементы с положительным
tabindex, по возрастанию значения. При равных значениях — в порядке DOM. - Потом — всё остальное фокусируемое: нативные контролы (
<a href>,<button>,<input>,<select>,<textarea>,<summary>, элементы сcontenteditable) и всё сtabindex="0"— строго в порядке DOM. - Из последовательности исключаются:
disabled, скрытые (display: none,visibility: hidden,hidden), содержимоеinert-поддерева,<a>без атрибутаhref.
Отдельно живёт эвристика :focus-visible. Спецификация не диктует точные правила — браузер смотрит на способ, которым фокус пришёл: клавиатура, программный focus() после клавиатурного действия, ввод текста → индикатор показывается; клик мышью по кнопке или ссылке → нет; клик в текстовое поле → показывается. Именно поэтому не стоит писать свою «эвристику» на классах вроде .using-keyboard — браузер уже сделал это лучше.
Проверить, что происходит на живой странице, проще всего так — открыть консоль и походить по сайту клавишей Tab:
// Показывает, где сейчас фокус. Вставьте в консоль DevTools и жмите Tab.
document.addEventListener('focusin', () => {
const el = document.activeElement;
console.log(el.tagName, el.className, el.textContent.trim().slice(0, 40));
});
Если в какой-то момент в консоли появляется BODY — фокус потерялся. Если появляются элементы, которых вы не видите на экране, — у вас невидимая ловушка из таблицы выше.
Частые ошибки
outline: noneв ресете «на всякий случай». Самая дорогая строчка в проекте: одним селектором ломает всю клавиатурную навигацию.- Свой индикатор бледнее стандартного. Серая рамка 1px на сером фоне формально есть, фактически невидима. Проверяйте контраст — минимум 3:1.
- Положительные
tabindex. Кажется, что вы «починили порядок». На деле вы сломали его для всех будущих компонентов. - Порядок в DOM не совпадает с визуальным. Особенно легко словить с flex
orderи в адаптивной вёрстке, где на мобильном блоки меняются местами через CSS. - Скрытые элементы, которые всё ещё в фокусе.
opacity: 0,height: 0, вынос за экран — фокус туда уходит. Используйтеdisplay: none,hiddenилиinert. - Фокус под липкой шапкой. Элемент сфокусирован, страница даже прокрутилась — но кнопка оказалась ровно под фиксированным хедером. Лечится
scroll-margin-topна фокусируемых элементах. disabledтам, где нуженaria-disabled. Отключённая кнопка выпадает из обхода — клавиатурный пользователь её просто не найдёт и не поймёт, почему форма не отправляется. Иногда честнее оставить кнопку фокусируемой сaria-disabled="true"и объяснить причину текстом.- Автофокус в неожиданном месте.
autofocusна поле поиска в середине страницы утаскивает и фокус, и прокрутку сразу после загрузки — читатель теряет начало страницы.
Итоги
- Фокус — курсор клавиатуры. Он всегда должен быть видимым и предсказуемым.
outline: noneбез замены — критический баг доступности, а не вопрос вкуса.:focus-visibleзакрывает старый спор: кольцо есть для клавиатуры и не мешает при клике мышью.- Индикатор — минимум 2px, контраст от 3:1, с отступом и не обрезанный.
- Порядок обхода задаёт DOM. CSS его не меняет — он только создаёт рассинхрон.
tabindex="0"— поставить в очередь;tabindex="-1"— сфокусировать из кода; положительные значения — никогда.- Самая честная проверка занимает минуту: уберите руку с мыши и пройдите страницу клавишей Tab.