Фокус: видимость и порядок

Фокус — это курсор клавиатуры. Если его не видно или он ходит не туда, сайтом просто нельзя пользоваться без мыши.

Фокус (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) по простому алгоритму:

  1. Сначала — все элементы с положительным tabindex, по возрастанию значения. При равных значениях — в порядке DOM.
  2. Потом — всё остальное фокусируемое: нативные контролы (<a href>, <button>, <input>, <select>, <textarea>, <summary>, элементы с contenteditable) и всё с tabindex="0" — строго в порядке DOM.
  3. Из последовательности исключаются: 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.
Проверьте себя
1. Разработчик убрал стандартное кольцо фокуса правилом *:focus { outline: none; }, потому что дизайнеру не нравилась синяя рамка при клике мышью. Что здесь правильнее сделать?
AОставить как есть — при клике мышью рамка действительно не нужна
BИспользовать :focus-visible: кольцо показывается для клавиатуры и не показывается при клике мышью
CЗаменить outline на положительные tabindex у важных кнопок
DДобавить aria-hidden="true" кнопкам, чтобы фокус на них не приходил
2. Что делает tabindex="-1"?
AПолностью запрещает фокусировать элемент — ни Tab, ни element.focus() не сработают
BСтавит элемент в самый конец порядка обхода по Tab
CУбирает элемент из обхода по Tab, но оставляет возможность сфокусировать его из кода через element.focus()
DСкрывает элемент от скринридера, как aria-hidden