Focus-visible на практике: убираем уродливый outline и не теряем клавиатурную навигацию
Каждый раз, когда дизайнер просит «убрать эту синюю обводку вокруг кнопок», у меня дёргается глаз. Через неделю после такой правки прилетает баг от пользователя, который ходит по сайту с клавиатуры, и приходится возвращать обводку обратно — но уже криво и с костылями.
За семь лет во фронтенде, последние два из которых я плотно занимаюсь доступностью в нашей дизайн-системе, у меня сложился короткий рецепт: :focus-visible закрывает 90% этой проблемы. Расскажу, как его применять без сюрпризов и какие грабли поджидают на больших проектах.
Чем :focus-visible отличается от :focus
Псевдокласс :focus срабатывает на любом фокусе: и когда пользователь кликает мышью, и когда переходит по Tab. Из-за этого выходит так: ткнул мышью в кнопку — и под ней висит синяя рамка до конца жизни. Дизайнерам это не нравится, и обводку выпиливают через outline: none. Параллельно ломается навигация с клавиатуры.
:focus-visible срабатывает только тогда, когда браузер считает, что фокус уместно показать визуально: переход по Tab, стрелки, Shift+Tab, программный фокус. На клик мышью по кнопке — не срабатывает. На клик по полю ввода — срабатывает (потому что в инпут можно начать печатать, и индикатор фокуса нужен).
Логика «когда показывать» зашита в браузер. Тебе не нужно её эмулировать — раньше для этого приходилось подключать focus-visible polyfill и расставлять классы вручную. Сейчас поддержка нативная во всех актуальных браузерах, включая Safari с весны 2022 года.
Базовый рецепт
Минимальный набор стилей, который я ставлю в reset проекта:
/* Сбрасываем дефолтный outline только когда фокус НЕ visible */
:focus:not(:focus-visible) {
outline: none;
}
/* И сразу даём аккуратный индикатор для focus-visible */
:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
border-radius: inherit;
}
Что здесь происходит. Первое правило снимает обводку только в случае, когда фокус есть, но :focus-visible не сработал — типичный кейс с кликом мыши по кнопке. Второе — рисует индикатор для клавиатурной навигации.
Я намеренно не пишу *:focus { outline: none; }. Это глобальный сброс, который потом аукнется в десятке мест: на ссылках, на инпутах, на кастомных контролах. Лучше точечно через :not(:focus-visible).
Цвет и контраст индикатора
Тут начинается интересное. По WCAG 2.2, индикатор фокуса должен иметь контраст 3:1 относительно соседних цветов. Это значит, что один и тот же синий не подойдёт и для светлой, и для тёмной темы.
В дизайн-системе, над которой я работала, мы держим две CSS-переменные:
:root {
--color-focus: #2563eb; /* синий для светлой темы */
--color-focus-ring: #2563eb33; /* для outline с альфой, если используем */
}
:root[data-theme="dark"] {
--color-focus: #93c5fd;
--color-focus-ring: #93c5fd55;
}
Когда внедряла фокус-стили в проекте на 200+ компонентов, в первый раз я попробовала задать один цвет. Оказалось, что на тёмной кнопке тёмно-синий outline почти не видно. Потом дольше всего ловили баг, что на жёлтой кнопке-предупреждении выбранный синий банально сливается. Поэтому теперь у нас отдельный токен для случая, когда фон контрастный: --color-focus-on-accent.
Двойная обводка: outline + box-shadow
Чистый outline хорош тем, что не влияет на размер бокса и не двигает соседей. Но иногда хочется индикатор с двумя слоями: тонкой обводкой и мягким halo вокруг неё. Делается так:
:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
box-shadow: 0 0 0 4px var(--color-focus-ring);
}
Учитывай, что box-shadow не двигает layout, но может перекрываться соседними элементами с overflow: hidden. Если у тебя кнопки в карточке с обрезкой — halo просто не покажется. Тогда лучше остановиться на чистом outline: он рисуется поверх и не зависит от overflow родителя.
Кастомные компоненты и составные контролы
На простых кнопках и ссылках всё работает само. Сложности начинаются там, где интерактивный элемент — это контейнер с внутренним input или button. Классический пример — кастомный чекбокс:
<label class="checkbox">
<input type="checkbox">
<span class="checkbox__box" aria-hidden="true"></span>
Согласен
</label>
Сам input ты прячешь визуально (но оставляешь его в DOM, чтобы скринридеры понимали). Фокус по Tab приходит на input, а ты хочешь, чтобы рамка появилась вокруг .checkbox__box. Делается через :has() или старым способом через соседний селектор:
.checkbox input:focus-visible + .checkbox__box {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
С :has() можно идти от родителя, но я по привычке использую соседний селектор — он работает везде и читается быстрее.
Ошибка, которую я вижу чаще всего
Когда команда впервые вводит у себя :focus-visible, кто-нибудь пишет:
button:focus-visible {
outline: none;
background: var(--color-hover);
}
То есть индикатор фокуса заменяют изменением фона. Технически работает, но есть три проблемы. Первая: ты не отличишь состояние hover от focused. Вторая: на тач-устройствах состояние «фокус» может остаться после tap'а, и пользователь увидит залипший hover-фон. Третья и главная: WCAG требует, чтобы индикатор фокуса был отличим от других состояний и имел свой контраст. Подмена фоном это требование часто не проходит.
Поэтому правило простое: индикатор фокуса — это отдельный визуальный слой, а не косметика существующих состояний.
Тестирование
Минимальный чек, который я делаю на любом новом компоненте:
- Кликаю мышью — обводки нет.
- Жму Tab от начала страницы и прохожу до этого компонента — обводка появилась, чёткая, не сливается с фоном.
- Открываю DevTools, в Issues смотрю, нет ли предупреждений по контрасту или фокусу.
- Включаю Windows High Contrast (или Forced Colors на Mac через эмуляцию в Chrome DevTools) — индикатор виден.
Forced colors — отдельная тема, но в двух словах: в этом режиме браузер игнорирует твои цвета и подставляет системные. Если ты задал outline-color: transparent или outline: none — индикатор пропадёт. Поэтому если уж убираешь дефолтный outline, обязательно дай взамен реальную обводку с цветом, а не 0 solid transparent.
Глобально или per-component
Меня часто спрашивают: делать общий стиль :focus-visible на всё подряд или прописывать в каждом компоненте отдельно. Мой ответ — комбинировать.
Глобально в reset/base:
:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
Это страховка. Если кто-то добавит новый компонент и забудет про фокус — он всё равно будет.
А в самих компонентах переопределяй outline-offset, цвет или форму, если нужно. Например, у круглой иконочной кнопки логично сделать border-radius: 50% у обводки, у инпута — другой цвет. Главное — не убирать индикатор полностью, только модифицировать.
Что запомнить
Используй :focus-visible вместо :focus для управления видимым индикатором — это нативно, без полифиллов и без классов на JS. Не убирай outline глобально, делай это только в паре :focus:not(:focus-visible). Держи цвет фокуса в CSS-переменной с отдельным значением для тёмной темы. И прогоняй компонент по короткому чек-листу: мышь, Tab, forced colors. Этого хватит, чтобы не словить обращение от пользователя со скринридером уже после релиза.