lenec ru

← все посты

Focus-visible на практике: убираем уродливый outline и не теряем клавиатурную навигацию

10K

Каждый раз, когда дизайнер просит «убрать эту синюю обводку вокруг кнопок», у меня дёргается глаз. Через неделю после такой правки прилетает баг от пользователя, который ходит по сайту с клавиатуры, и приходится возвращать обводку обратно — но уже криво и с костылями.

За семь лет во фронтенде, последние два из которых я плотно занимаюсь доступностью в нашей дизайн-системе, у меня сложился короткий рецепт: :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 требует, чтобы индикатор фокуса был отличим от других состояний и имел свой контраст. Подмена фоном это требование часто не проходит.

Поэтому правило простое: индикатор фокуса — это отдельный визуальный слой, а не косметика существующих состояний.

Тестирование

Минимальный чек, который я делаю на любом новом компоненте:

  1. Кликаю мышью — обводки нет.
  2. Жму Tab от начала страницы и прохожу до этого компонента — обводка появилась, чёткая, не сливается с фоном.
  3. Открываю DevTools, в Issues смотрю, нет ли предупреждений по контрасту или фокусу.
  4. Включаю 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. Этого хватит, чтобы не словить обращение от пользователя со скринридером уже после релиза.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.