Доступні стани фокусу, які не виглядають як сміття

Було корисно?

Якщо ви коли-небудь випустили «полірований» інтерфейс, а потім виявили, що користувачі клавіатури не розуміють, де вони зараз, — ви зустріли найтихіший інцидент у веб-інженерії.
Сайт все ще завантажується. Метрики виглядають нормально. Але хтось натискає Tab — і інтерфейс перетворюється на будинок з привидами: двері відчинені, світло вимкнене, і ви не впевнені,
у якій кімнаті ви перебуваєте.

Стани фокусу — це надійність для людей. Це ваш шар спостереження для клавіатурної навігації. І так, вони можуть виглядати добре без того, щоб позбавляти
доступності або перетворювати дизайн-систему на неонову катастрофу.

Що насправді роблять стани фокусу (і чому це має значення)

«Фокус» — це спосіб браузера сказати: цей елемент отримає введення з клавіатури, якщо користувач щось введе або активує.
Для користувача миші курсор — це канал зворотного зв’язку. Для користувача клавіатури індикатор фокусу — канал зворотного зв’язку. Видалили його — і ви не «очистили інтерфейс»;
ви забрали у них єдину панель керування.

У термінах SRE, стилізація фокусу подібна до логування. Коли вона присутня й чітка, ви її не помічаєте. Коли її немає, ви витрачаєте години на здогадки. І тому, що більшість вашої
команди користується трекпадом, ви не помітите проблему, поки хтось із іншими методами вводу (тільки клавіатура, перемикачі, скрінрідери, просунуті користувачі) не натрапить на глухий кут.

Гідна реалізація фокусу надійно робить три речі:

  • Вона з’являється, коли користувачу це потрібно. Зазвичай під час навігації клавіатурою, а не при кожному кліку миші.
  • Вона візуально очевидна. Не «технічно присутня, але того ж кольору, що й фон». Очевидна.
  • Вона відслідковує реальний інтерактивний елемент. Не wrapper div. Не випадковий дочірній елемент. Сам елемент, який активується.

Також: фокус — це не те саме, що hover. Hover каже «ваш вказівник поруч». Фокус каже «ви тут». Ставтеся до нього як до першорангового стану.

Кілька історичних фактів, які варто знати

Вам не потрібно вчити історію стандартів напам’ять. Але трохи контексту допомагає зрозуміти, чому браузери поводяться так, як вони поводяться, і чому деякі «хитрі» CSS-трюки
насправді є часовими бомбами.

  1. Ранні браузери за замовчуванням показували видимі контури фокусу, бо клавіатура була основним способом навігації набагато раніше, ніж панували «піксельно-ідеальні» сторінки маркетингу.
  2. CSS1 не мав навороченої стилізації фокусу. Контур фокусу здебільшого був справою UA (user agent), і очевидно було зроблено так, щоб його важко було випадково повністю видалити.
  3. Епоха «outline: none» вибухнула з плоским дизайном. Команди видаляли контури, щоб підігнати під макети, а потім забували їх замінити. Веб став тихішим і менш навігабельним.
  4. WCAG 2.0 (2008) вимагав клавіатурної доступності, але не наказував конкретний вигляд фокусу, тому багато команд виконували вимоги «на папері», одночасно випускаючи невидимий фокус.
  5. :focus-visible з’явився, щоб вирішити реальний UX-конфлікт: користувачам потрібен контур фокусу для навігації клавіатурою, але не для кожного кліку миші.
  6. Браузери використовують евристику для видимості фокусу (модальність вводу, недавня клавіатурна взаємодія). Ось чому ваш контур іноді з’являється «випадково», якщо ви не в курсі правил.
  7. Режими високої контрастності змінили правила гри. Примусові кольори ОС можуть перевизначати палітру, але контури часто виживають. Якщо ви видаляєте контури, ви прибираєте найстійкіший індикатор.
  8. Сучасний WCAG (2.2) загострив очікування щодо фокусу з чіткішими вимогами до вигляду та видимості фокусу, підвищуючи вартість «мінімальних» індикаторів.
  9. Позови за доступність підштовхнули теми фокусу до рад правління. Найшвидший спосіб отримати бюджет на виправлення фокусу — на жаль, юридичний лист і обурений клієнт.

Три стовпи: focus-visible, skip-посилання та хороші контури

Якщо ви хочете систему фокусу, яка витримає реальний трафік, реальні пристрої та реальних людей, зробіть ці три речі й припиніть імпровізувати:

  • Використовуйте :focus-visible, щоб показати чіткий індикатор при навігації клавіатурою без додавання візуального шуму при кліках миші.
  • Забезпечте skip-посилання, щоб користувачі клавіатури могли пропустити повторну навігацію й швидко потрапити до основного контенту.
  • Використовуйте контури (або кільцеві ефекти на їх зразок), які мають достатній контраст, не обрізаються й не покладаються на тонкі зміни кольору.

Можете ставати вишуканішими пізніше. Якщо у вас нема цих трьох — ваша історія з доступністю в основному «на рівні враження».

:focus-visible — розумчий за замовчуванням

Старий світ був бінарним: :focus завжди показувався, або ви взагалі його видаляли й сподівалися, що ніхто не помітить. Новий світ використовує
:focus-visible, щоб відображати індикатори фокусу лише тоді, коли браузер вважає, що користувач навігує через клавіатуру (або подібні методи).

Практичне правило:

  • :focus — це стан: елемент сфокусований.
  • :focus-visible — це підказка презентації: показуйте кільце, коли браузер вважає це потрібним.

Базовий CSS, який працює в продакшені

Ось та частина, де дизайнери нервують, а інженери тягнуться за reset-стилями. Заспокойтеся. Використовуйте послідовне кільце й застосовуйте його до інтерактивних контролів.

cr0x@server:~$ cat focus.css
:root {
  --focus-ring-color: #1b6ef3;
  --focus-ring-offset: 3px;
  --focus-ring-width: 3px;
}

/* Default: no special ring on mouse click */
:where(a, button, input, select, textarea, summary, [tabindex]):focus {
  outline: none;
}

/* Keyboard-visible ring */
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
  outline: var(--focus-ring-width) solid var(--focus-ring-color);
  outline-offset: var(--focus-ring-offset);
}

Що це робить:

  • Прибирає стандартний контур на :focus, щоб при кліках миші не лишалися кільця скрізь.
  • Додає чіткий контур на :focus-visible, щоб навігація клавіатурою завжди мала видимий маркер.

Чому :where()? Це зберігає низьку специфічність. Ви зможете перевизначати це в CSS компонентів без каскадних воєн.

Якщо ви думаєте «але прибирання outline — це погано», ви абстрактно праві. Це прийнятно лише коли ви повертаєте сильний індикатор на :focus-visible.
Режим відмови — це прибрати контури й забути про заміну, саме так половина інтернету опинилася в stealth-режимі фокусу.

Перевірка підтримки браузерів

Більшість сучасних браузерів підтримують :focus-visible. Але продакшн-системи живуть достатньо довго, щоб зустріти дивакуватих клієнтів.
Якщо потрібен fallback, можна стилізувати :focus, а потім приглушувати при взаємодії через вказівник за допомогою невеликого скрипта, або використати поліфіл.
Тримайте fallback мінімальним і тестуйте його. Не створюйте власну фреймворк-для-детекції-модальності; ви помилитеся і вам не сподобається це відлагоджувати.

Одна цитата, що тримає вас чесним

Надія — це не стратегія. — генерал Гордон Р. Салліван

Ця фраза стосується станів фокусу більше, ніж комусь хотілося б визнати. «Користувачі, мабуть, використовують мишу» — це надія. «Ми протестували навігацію клавіатурою» — це стратегія.

Skip-посилання — це найдешевший виграш з доступності, який ви можете відправити. Вони також чудовий лакмусовий тест: якщо в вашій організації не можуть домовитися про додавання skip-посилання,
вам буде важко з будь-якими складнішими завданнями.

Skip-посилання вирішує конкретну біль: повторну навігацію. На сайті з великою кількістю контенту заголовок може містити десятки tabbable-елементів (посилання на логотип, навігація, пошук, меню користувача).
Користувачам клавіатури не варто табом проходити все це на кожній сторінці, щоб дістатися до основного контенту.

Шаблон розмітки

cr0x@server:~$ cat skip-link.html


Ключові деталі:

  • Skip-посилання має бути першим або близько до першого в DOM-послідовності, щоб бути доступним негайно.
  • href="#main" має вказувати на реальний елемент, який існує на кожній сторінці, що використовує цей патерн.
  • tabindex="-1" на <main> дозволяє програмно перемістити туди фокус у браузерах, які за замовчуванням не фокусують неінтерактивні елементи.

CSS, що ховає його поки не в фокусі

cr0x@server:~$ cat skip-link.css
.skip-link {
  position: absolute;
  top: 0;
  left: 0;
  padding: 0.75rem 1rem;
  transform: translateY(-120%);
  background: #111;
  color: #fff;
  z-index: 9999;
}

.skip-link:focus-visible {
  transform: translateY(0);
  outline: 3px solid #fff;
  outline-offset: 2px;
}

Це правильний тип «приховування»: він візуально за межами екрану, але стає видимим при фокусі. Не використовуйте display:none або visibility:hidden.
Вони видаляють елемент з дерева доступності і клавіатурної навігації. Це не «приховано», це «видалено».

Жарт №1: Найшвидший спосіб знайти відсутнє skip-посилання — пройти табами по мегаменю один раз — раптом ви стаєте зацікавленим у доступності.

Контури, що виглядають добре (і витримують темну тему)

Кільця фокусу не повинні бути потворними. Вони повинні бути видимими. Це різні проблеми.

«Контур виглядає потворно» часто заміняє думку «кільце не відповідає дизайн-системі». Добре. Налаштуйте його під дизайн. Але не робіть його настільки слабким, щоб він став невидимим.
Якщо дизайнер хоче 1px світло-сіре кільце на білому фоні, ваша задача — ввічливо сказати «ні» і випустити те, що користувачі бачать.

Outline vs box-shadow vs гібридні кільця

Є три поширені підходи:

  • outline: Простий, стійкий, працює в примусових кольорах, не впливає на макет. Класика з причини.
  • box-shadow: Більше візуальної гнучкості (світіння, м’які краї), але може обрізатися overflow:hidden і зникати в примусових кольорах.
  • Гібрид: Використовуйте outline для сумісності з примусовими кольорами й додавайте box-shadow для естетики.

Кільце, що виглядає сучасно, але залишається читабельним

cr0x@server:~$ cat ring.css
:root {
  --ring: #2563eb;
  --ring-outer: color-mix(in srgb, var(--ring) 30%, transparent);
}

:where(a, button, input, select, textarea):focus-visible {
  outline: 3px solid var(--ring);
  outline-offset: 3px;
  box-shadow: 0 0 0 6px var(--ring-outer);
  border-radius: 6px;
}

Рішення, вбудовані в цей CSS:

  • Ширина 3px зазвичай помітна на поширених фонах.
  • Зсув 3px запобігає злиттю кільця з бордерами і виглядає навмисно.
  • М’яка зовнішня аура допомагає на зайнятих фонах без потреби в неоновому кільці.

Темна тема: не просто інвертуйте кольори

У темному режимі насичене блакитне кільце все ще може працювати, але потрібно перевіряти контраст відносно найпомітніших пікселів поруч, а не лише фону сторінки.
Часто кільця стоять на картках, чіпах і багато шарових поверхнях. Кільце має бути помітним на всіх них.

cr0x@server:~$ cat dark-mode.css
@media (prefers-color-scheme: dark) {
  :root {
    --ring: #93c5fd;
  }
}

Оберіть колір кільця, що працює на різних поверхнях. Якщо ваш додаток використовує кілька рівнів фону, розгляньте подвійне кільце (внутрішнє + зовнішнє) для гарантії видимості.

Примусові кольори / режим високої контрастності

Коли ОС примушує кольори, ваша ретельно підібрана палітра може бути проігнорована. Контури частіше лишаються видимими.
Підтримайте це явно:

cr0x@server:~$ cat forced-colors.css
@media (forced-colors: active) {
  :where(a, button, input, select, textarea):focus-visible {
    outline: 2px solid CanvasText;
    outline-offset: 2px;
    box-shadow: none;
  }
}

Рішення тут просте: в примусових кольорах обирайте надійність замість стилю. Колір вашого бренду не важливіший за можливість когось користуватися вашим продуктом.

Компоненти, що ламають фокус (і як цього уникнути)

Звичні підозрювані

  • Користувацькі кнопки, зроблені з div з обробниками click, без підтримки клавіатури й без стилів фокусу.
  • Надмірні CSS-ресети, що глобально стирають контури, включно з віджетами третіх сторін.
  • Контейнери з overflow:hidden, які обрізають фокуси, реалізовані через box-shadow.
  • Модальні вікна та висувні панелі, що неправильно захоплюють фокус або не відновлюють його після закриття.
  • Зміни маршруту в SPA, що переміщують контент без переміщення фокусу, залишаючи користувача «десь» в старому DOM.

Не винаходьте нові інтерактивні елементи

Використовуйте нативні елементи, коли це можливо. <button> дає вам клавіатурну активацію, фокусованість, семантику й поведінку «безкоштовно».
Переробити це з div — це як реімплементувати TCP, бо ви хочете «більше контролю».

Управління фокусом: нудні правила, що тримають вас в безпеці

  • При відкритті модального: перемістіть фокус у модаль (зазвичай на його заголовок або перше поле).
  • Поки модаль відкритий: захопіть фокус всередині модального (циклічна послідовність табів).
  • Після закриття модального: відновіть фокус на елементі, що відкрив його.

Якщо пропустити крок відновлення, користувачі клавіатури «випадають» з потоку і втрачають своє місце. Це не дрібний UX-проблема; це функціональний збій.

Швидкий план діагностики

Коли фокус «зламаний», команди зазвичай метушаться. Хтось звинувачує CSS. Хтось — браузер. Хтось пропонує переписати все.
Не робіть цього. Діагностуйте, як регресію затримки: ізолюйте, відтворіть, виявити шар проблеми.

Перше: чи фактично рухається фокус?

  • Натисніть Tab з початку сторінки.
  • Спостерігайте за адресним рядком і сторінкою; перевірте, чи фокус входить у документ.
  • Якщо нічого не відбувається, перевірте, чи сторінка не перехоплює події клавіш.

Друге: індикатор відсутній чи просто невидимий?

  • Використайте DevTools, щоб інспектувати поточний сфокусований елемент (:focus стан).
  • Перевірте обчислені стилі: outline, box-shadow, зміни фону.
  • Шукайте outline: none, що йде з reset або базового класу компонента.

Третє: чи хтось краде або блокує фокус?

  • Відкрийте та закрийте модалі. Чи повертається фокус на тригер?
  • У SPA перейдіть між маршрутами й перевірте, чи фокус рухається до значущого заголовка.
  • Перевірте на наявність неочікуваних autofocus атрибутів і скриптів, що викликають focus() на таймерах.

Четверте: чи порядок табів адекватний?

  • Перевірте на наявність позитивних значень tabindex (tabindex="1", тощо).
  • Шукайте приховані, але фокусовані елементи.
  • Підтвердіть, що відключені контролі не залишаються фокусованими (поширено для кастомних компонентів).

П’яте: чи спеціальні режими ламають його?

  • Тестуйте в режимі примусових кольорів.
  • Тестуйте при масштабі 200%.
  • Тестуйте в темній темі, якщо підтримується.

Жарт №2: Кільце фокусу, що видно лише на моніторі вашого дизайнера, — це не «вираження бренду», це сліпа пляма моніторингу.

Практичні завдання: команди, результати, рішення

Це розраховано на того, хто має кодову базу, збирання та відчуття терміновості. Кожне завдання містить команду, що типовий вивід означає,
і рішення, яке ви приймаєте.

Завдання 1: Знайти глобальне видалення outline у CSS

cr0x@server:~$ rg -n "outline\s*:\s*none" web/ styles/
styles/reset.css:42:*:focus{outline:none}
web/components/button.css:18:.btn:focus{outline:none}

Що означає вивід: Маєте щонайменше два правила, що видаляють контури, одне глобально. Глобальне правило — типовий підпалювач.

Рішення: Замініть глобальне видалення на стратегію :focus-visible і переконайтеся, що кожен інтерактивний елемент має видимий індикатор фокусу.

Завдання 2: Перевірити покриття focus-visible (або його відсутність)

cr0x@server:~$ rg -n ":focus-visible" web/ styles/
styles/focus.css:12::where(a, button, input):focus-visible { outline: 3px solid var(--ring); }

Що означає вивід: Маєте одне правило для focus-visible. Добрий початок, але перевірте, чи воно застосовується до всіх компонентів і не перекривається.

Рішення: Переконайтеся, що селектор охоплює всі релевантні інтерактивні контролі й має низьку специфічність, щоб стилі компонентів могли його розширювати, а не боротися з ним.

Завдання 3: Виявити елементи з позитивним tabindex

cr0x@server:~$ rg -n "tabindex\s*=\s*\"[1-9]" web/
web/pages/legacy-dashboard.html:88:
web/pages/legacy-dashboard.html:101:

Що означає вивід: Позитивний tabindex використовується для примусового порядку табів. Це часто створює непередбачувану навігацію в різних браузерах і скрінрідерах.

Рішення: Рефакторити у порядку DOM з tabindex="0" лише коли потрібно. Розглядайте позитивний tabindex як баг, якщо немає дуже специфічної, протестованої причини.

Завдання 4: Знайти div, що прикидається кнопкою

cr0x@server:~$ rg -n "role\s*=\s*\"button\"" web/
web/components/filters.html:14:
web/components/menu.html:55:

Що означає вивід: Маєте кастомні інтерактивні елементи. Вони потребують обробників клавіатурної активації, стилів фокусу й коректності ARIA.

Рішення: Замініть на <button>, де можливо. Якщо ні — переконайтеся в підтримці Enter/Space, стилізації focus-visible і правильних ARIA-станах.

Завдання 5: Перевірити обтинання, що вбиває кільця фокусу

cr0x@server:~$ rg -n "overflow:\s*hidden" web/components styles
web/components/card.css:7:.card{overflow:hidden;border-radius:12px}
web/components/modal.css:22:.modal-body{overflow:hidden}

Що означає вивід: Будь-який індикатор фокусу, реалізований через box-shadow, може бути обрізаний всередині цих контейнерів.

Рішення: Віддавайте перевагу outline (не обрізається), або підправте стратегію overflow у контейнера, або додайте внутрішнє кільце, що не покладається на тіні поза межами елементу.

Завдання 6: Виявити наявність skip-посилання в шаблонах

cr0x@server:~$ rg -n "Skip to main content" web/
web/layouts/base.html:3:

Що означає вивід: Skip-посилання є в базовому лейауті. Тепер перевірте, чи воно не видаляється сторінками з особливими лейаутами і чи #main існує всюди.

Рішення: Додайте CI-тест, який провалить збірку, якщо #main відсутній у згенерованому HTML або якщо текст skip-посилання відсутній.

Завдання 7: Перевірити, що головна ціль існує на всіх сторінках

cr0x@server:~$ rg -n 'id="main"' web/pages
web/pages/home.html:12:
web/pages/pricing.html:9:
web/pages/blog.html:15:

Що означає вивід: Одна сторінка використовує <div id="main"> замість <main> і може бути нефокусованою.

Рішення: Уніфікуйте на <main id="main" tabindex="-1"> на всіх сторінках або переконайтеся, що ціль може отримати фокус надійно.

Завдання 8: Запустити локально набір accessibility-тестів (Playwright)

cr0x@server:~$ npm test
> webapp@1.0.0 test
> playwright test

Running 18 tests using 4 workers
  ✓ a11y: skip link is reachable (1.2s)
  ✗ a11y: focus indicator visible on buttons (2.0s)
    Error: expected focus ring to be visible on .btn-primary, but computed outline-style was 'none'

Що означає вивід: Тест виявив, що на вашій первинній кнопці немає видимого контуру при фокусі.

Рішення: Виправте CSS компонента і збережіть тест. Не позначайте його як flaky. Кільце фокусу не є необов’язковою функціональністю.

Завдання 9: Виявити бої специфічності, що впливають на focus-visible

cr0x@server:~$ rg -n "\.btn.*:focus" web/components/button.css
18:.btn:focus{outline:none}
24:.btn-primary:focus-visible{outline:none;box-shadow:none}

Що означає вивід: CSS компонента явно видаляє стилі focus-visible. Це підпис «хтось хотів, щоб його не було».

Рішення: Видаліть ці правила або замініть їх на сумісний стиль для :focus-visible. Якщо дизайн хоче кастомні стилі — чудово, але випускайте видиме кільце, а не нічого.

Завдання 10: Перевірити на наявність шкідливого autofocus

cr0x@server:~$ rg -n "\bautofocus\b" web/
web/pages/login.html:22:
web/components/modal.html:8:

Що означає вивід: Autofocus може несподівано вкрасти фокус, особливо коли модалі монтуються або маршрути змінюються.

Рішення: Залишайте autofocus лише там, де це явно корисно (поле логіну часто підходить). Замініть autofocus в модалях на явне управління фокусом при відкритті.

Завдання 11: Перевірити, що ціль skip-посилання фокусована під час виконання (простий Node-чек)

cr0x@server:~$ node -e "const fs=require('fs');const html=fs.readFileSync('dist/pricing.html','utf8');console.log(/id=\"main\"/.test(html), /tabindex=\"-1\"/.test(html));"
true true

Що означає вивід: Згенерований HTML містить id="main" і tabindex="-1".

Рішення: Додайте такий чек у CI для шаблонів, що варіюються. Це грубо, але ефективно ловить регресії.

Завдання 12: Проінспектувати зібраний CSS на випадкове видалення outline

cr0x@server:~$ rg -n "outline:none" dist/assets/app.css | head
1882:*:focus{outline:none}
45110:.btn:focus{outline:none}

Що означає вивід: Ваш зібраний артефакт все ще містить глобальне видалення outline. Навіть якщо у вихідному коді все гаразд, залежність або крок збірки може його повторно додати.

Рішення: Виправте це в джерелі (reset-стилі, override залежності або pipeline збірки). Потім додайте перевірку на етапі збирання, яка провалить збірку, якщо *:focus{outline:none} з’явиться.

Завдання 13: Запустити Lighthouse CI і інтерпретувати помилку, пов’язану з фокусом

cr0x@server:~$ npx lhci autorun
✅  .lighthouseci/ collected 1 run(s)
⚠️  Accessibility score: 0.92
   - [fail] Background and foreground colors do not have a sufficient contrast ratio.
   - [warn] Interactive elements do not have a focus indicator.

Що означає вивід: Автоматизовані інструменти вказують на проблеми з індикатором фокусу. Вони можуть не вказати точний компонент, але це сигнал, що ваш базовий рівень ненадійний.

Рішення: Використайте це як гейт у CI. Потім доповніть таргетованими клавіатурними тестами критичних сценаріїв (чек-аут, логін, дії адміністратора).

Завдання 14: Використати Git, щоб знайти коли фокус поламався

cr0x@server:~$ git log -p -S "outline:none" -- web/styles/reset.css | head -n 20
commit 7c2a1b9d3d2c1a4b9d0c1f8e3a2b7f3c1d9a8b7c
Author: dev 
Date:   Tue May 14 10:22:11 2024 -0700

    Align focus styles with new design system

diff --git a/web/styles/reset.css b/web/styles/reset.css
@@ -39,6 +39,7 @@
 * { box-sizing: border-box; }
-*:focus { outline: auto; }
+*:focus { outline: none; }

Що означає вивід: Коміт навмисно видалив контури. Повідомлення натякає на узгодження зі новою дизайн-системою, але diff показує регресію доступності.

Рішення: Відкотіть або виправте з відповідною стилізацією :focus-visible. Також додайте пункти в чекліст рев’ю, щоб «узгодження з дизайном» не стало універсальним виправданням.

Три корпоративні міні-історії з полів

Міні-історія 1: Інцидент через хибне припущення

Середня SaaS-компанія випустила новий заголовок навігації: мегаменю, перемикач продукту, нотифікації — звичний набір «ми виросли».
Команда випустила це за feature-flag, провела швидкий smoke-тест і визнала роботу завершеною.

Хибне припущення: «Якщо це працює з мишею, то працює». Раніше вони глобально видалили стандартний контур, і старий UI мав кастомні стилі фокусу для кількох компонентів. Новий заголовок використовував dropdown від третьої сторони і саморобний компонент «pill». Жоден з них не мав стилізації focus-visible.

Перший звіт не прийшов із аудиту з доступності. Він прийшов від enterprise-підтримки: політика безпеки клієнта вимагала клавіатурної навігації для певних робочих процесів, і клієнт не міг виконати критичну адміністративну дію, бо губив своє місце в заголовку.

Інженери відтворили проблему за кілька хвилин: таби в заголовок заходили, але нічого не було видимо сфокусовано. Люди натомість натискали випадково, щоб відновити роботу.
UI «працював», але фактично був непридатний для роботи лише з клавіатурою. Це — outage, якщо метод вводу вашого користувача — клавіатура.

Виправлення було жорстоко простим: відправити базовий :focus-visible across всіх інтерактивних елементів, потім уточнювати по компонентах.
Урок закріпився: не можна покладатися на бібліотеки компонентів, якщо ви саботуєте фокус глобально.

Міні-історія 2: Оптимізація, що відкотилася

Інша компанія мала мандат на продуктивність. Фронт-енд бандл був роздутим, тому вони ввели агресивний крок purge CSS і спринт «оновлення всього».
Конфігурацію purge налаштували так, щоб зберігати лише селектори, виявлені в шаблонах на етапі збірки.

Відкат: стилі focus-visible були визначені в загальному стилі й застосовувалися через селектори :where(), а також через набір утилітних класів, що з’являлися динамічно (модалі, розділення коду по маршрутах). Purge не «побачив» ці селектори під час статичного аналізу. Він їх видалив.

У продакшені користувачі клавіатури почали повідомляти про дивну поведінку: на деяких сторінках кільця були, на інших — ні. Деякі модалі були в порядку, інші — невидимі.
Це виглядало випадково, що найгірше, бо це породжує фольклор і cargo-cult виправлення.

Налагодження зайняло більше часу, бо команда спочатку звинувачувала браузерні особливості. Справжня проблема — pipeline, що видаляє критичний CSS.
Коли вони порівняли збірку з вихідниками, все стало очевидним.

Виправлення: safelist селектори focus-visible і будь-які патерни класів, що використовуються для кілець фокусу. Потім додати автоматичну перевірку на наявність базового CSS фокусу в фінальному артефакті. Продуктивність важлива, але не за рахунок можливості користувачів навігувати.

Міні-історія 3: Нудна, але правильна практика, що врятувала день

Велика команда внутрішнього адмін-інструменту мала звичку, що не була гламурною: кожен новий компонент повинен пройти клавіатурний smoke-test перед мержем.
Це не був великий формальний процес. Просто чекліст у шаблоні PR: пройти табами, Shift+Tab назад, активувати Enter/Space, підтвердити видиме кільце фокусу,
і перевірити, що порядок табів логічний.

Під час рефакторингу вони замінили нативний select на кастомний «searchable dropdown». Це виглядало круто. Але також ввело тонку пастку фокусу: коли dropdown відкривався, фокус переходив у listbox, але при закритті не поверталося на тригер. Користувачі клавіатури опинялися «позаду» інтерфейсу, таблячи по прихованим елементам.

Розробник помітив це до рев’ю, бо чекліст змусив його пройти сценарій табами. Він виправив це, зберігши тригер елемента, перемістивши фокус у dropdown при відкритті і відновлюючи фокус при закритті.

Ніяких інцидентів. Ніяких обурених тікетів. Ніякого ескалації до керівництва. Просто маленька нудна практика, що запобігла повільно йдучому дефекту.

В операціях ми шануємо нудні системи, бо вони прогнозовані. Клавіатурна доступність — те саме. Зробіть її нудною. Зробіть її стандартною.

Поширені помилки: симптом → причина → виправлення

1) Симптом: «Tab працює, але я не бачу, де я.»

Причина: Глобальне видалення контуру (*:focus{outline:none}) без видимої заміни, або колір кільця занадто близький до фону.

Виправлення: Впровадьте базове кільце :focus-visible з достатнім контрастом і зсувом; видаліть тотальне придушення outline або обґрунтуйте його локально.

2) Симптом: «Кільце фокусу показується при кліку і дизайнери його ненавидять.»

Причина: Стилізація :focus замість :focus-visible, або браузери трактують взаємодію як подібну до клавіатурної через евристику модальності.

Виправлення: Перенесіть видиме кільце на :focus-visible. Тримайте мінімальний стиль на :focus лише як fallback, якщо потрібно.

3) Симптом: «Деякі кнопки показують фокус, інші — ні.»

Причина: Переважні перевизначення на рівні компонентів, що видаляють outline/box-shadow; конфлікти специфічності; purge CSS, що видалив базові селектори.

Виправлення: Аудит CSS компонентів на наявність outline:none; тримайте базове правило низької специфічності; safelist селектори focus у налаштуваннях purge; додайте перевірки на етапі збірки.

4) Симптом: «Кільце фокусу обрізається.»

Причина: Кільця через box-shadow обрізаються overflow:hidden або контейнерами прокрутки.

Виправлення: Використовуйте outline для основного кільця; збільшіть outline-offset; змініть стратегію overflow контейнера або додайте паддинг, щоб уникнути обрізання.

5) Симптом: «Skip-посилання є, але нічого не робить.»

Причина: Ціль-якір відсутній, дубльовані ID або ціль не фокусована в деяких браузерах.

Виправлення: Переконайтеся, що id="main" існує в єдиному екземплярі; додайте tabindex="-1" до цілі; підтвердіть наявність на всіх шаблонах.

6) Симптом: «Користувачі клавіатури застрягають у модалі.»

Причина: Неправильно реалізований focus trap або порядок табів включає приховані елементи поза модаллю.

Виправлення: Реалізуйте протестований focus trap; вимкніть прокрутку і взаємодію з фоном; позначте фон як inert, якщо підтримується; відновіть фокус при закритті.

7) Симптом: «Після зміни маршруту фокус загублений або лишається на старому UI.»

Причина: Навігація SPA змінює контент без переміщення фокусу; фокус лишається на видаленому елементі або на елементі навігації.

Виправлення: При зміні маршруту переміщуйте фокус на значущий заголовок або основний контейнер (з tabindex="-1"). Тримайте це послідовним між маршрутами.

8) Симптом: «Скрінрідер оголошує дивні речі; клавіатурна поведінка непослідовна.»

Причина: Кастомні інтерактивні елементи без семантики; некоректні ролі/стани ARIA; поєднання role="button" з вкладеними посиланнями тощо.

Виправлення: Віддавайте перевагу нативним контролам. Якщо кастомні — реалізуйте правильні клавіатурні події, ARIA-стани і переконайтеся, що стилі фокусу застосовуються до реально фокусованого вузла.

Чеклісти / покроковий план

Крок за кроком: випустити надійну базу за один спринт

  1. Перелікуйте придушення фокусу. Пошукайте outline:none і box-shadow:none на станах фокусу. Видаліть або обґрунтуйте кожен випадок.
  2. Додайте базове правило :focus-visible. Покрийте як посилання, кнопки, поля форм, так і все з tabindex.
  3. Визначте токени кільця. Оберіть кольори, ширину й зсув кільця як змінні дизайн-системи. Зробіть їх чутливими до теми.
  4. Додайте skip-посилання в базовий лейаут. Переконайтеся, що #main існує на всіх сторінках і фокусований з tabindex="-1".
  5. Підлатайте топ-10 компонентів. Кнопки, посилання, інпути, селекти, меню, таби, чіпи, перемикачі, модалі та dropdown-и.
  6. Протестуйте критичні потоки лише клавіатурою. Логін, чек-аут, зміни налаштувань, дії, що видаляють дані, і все, що може заблокувати користувача.
  7. Покрийте примусові кольори і темну тему. Додайте @media (forced-colors: active) та перевірте видимість кільця в темній темі.
  8. Додайте перевірки в CI. Провалюйте збірки, якщо базовий CSS фокусу відсутній в артефакті або якщо skip-посилання/ціль main відсутні.

Чекліст прийнятності клавіатурної навігації (готово для PR)

  • Порядок табів відповідає візуальному порядку (або принаймні не дивує).
  • Немає позитивних tabindex без письмового пояснення і тестів.
  • Кожен інтерактивний елемент має видимий індикатор фокусу при навігації клавіатурою.
  • Skip-посилання — перший доступний елемент і працює.
  • Модалі захоплюють фокус і відновлюють його на тригер при закритті.
  • Dropdown-и й меню підтримують Escape для закриття й повертають фокус.
  • Кільце фокусу не обрізається стилями контейнера.
  • Режим примусових кольорів все ще чітко показує фокус.

Чекліст дизайн-системи: «гарно», без шкоди користувачам

  • Ширина кільця ≥ 2px у більшості контекстів; 3px безпечніше.
  • Зсув кільця робить його відмінним від бордерів.
  • Контраст кольору кільця перевірено на всіх поверхнях (картки, банери, інпути, наближені до вимкненого стану).
  • Не покладайтеся лише на зміну кольору всередині елемента (наприклад, зміна фону на 5%).
  • Віддавайте перевагу outline (або включайте outline) для стійкості до примусових кольорів.

Питання та відповіді

1) Чи варто мені коли-небудь використовувати outline: none?

Так, але тільки з видимою заміною для клавіатурної навігації. Безпечний патерн: прибрати дефолтний контур на :focus, додати сильну стилізацію на :focus-visible.
Якщо ви не можете гарантувати заміну у всіх компонентах, не видаляйте його глобально.

2) Чому :focus-visible іноді показується при кліку мишею?

Браузери використовують евристику. Якщо ви нещодавно використовували клавіатуру або взаємодієте з контролом, де індикатор фокусу корисний (наприклад, текстові поля),
браузер може вирішити, що фокус «видимий». Не боріться з цим надто жорстко. Мета — зручність, а не естетична чистота.

3) Чи достатньо тонкої зміни фону як індикатора фокусу?

Зазвичай ні. Тонкі заповнення провалюються на зайнятих фонах, в темній темі та на низькоякісних дисплеях. Використовуйте кільце, яке чітко видно і витримує примусові кольори.
Думайте «очевидно», а не «витончено».

4) Чому моє box-shadow кільце фокусу зникає в деяких компонентах?

Бо в макеті щось його обрізає: overflow:hidden, контейнери прокрутки або stacking contexts. Використовуйте outline як основне кільце
або забезпечте достатньо простору й відсутність обрізання навколо сфокусованих елементів.

5) Чи мають значення skip-посилання в односторінкових додатках (SPA)?

Так. SPA часто мають постійні заголовки й динамічні регіони контенту. Skip-посилання плюс послідовне управління фокусом при зміні маршруту роблять додаток стабільним для користувачів клавіатури.

6) Куди має йти фокус після зміни маршруту?

Зазвичай на головний заголовок (наприклад, <h1>) або на основний контейнер. Зробіть ціль фокусованою з tabindex="-1" і перемістіть туди фокус навмисно.
Не залишайте фокус на елементі навігації, що викликав зміну маршруту; так користувачі втрачають контекст.

7) У чому проблема з tabindex="5" і подібними?

Позитивний tabindex створює окремий порядок табів, що може стати непослідовним і крихким при зміні DOM. Він також псує очікування користувачів з допоміжними технологіями.
Віддавайте перевагу порядку DOM і tabindex="0" лише коли потрібно зробити не-нативний елемент фокусованим.

8) Як зробити стилі фокусу послідовними в дизайн-системі?

Визначте токени кільця (колір, ширина, зсув) в :root, застосуйте правило бази з низькою специфічністю, потім дозвольте компонентам розширювати, а не перекривати його.
Додайте CI-перевірки, що гарантують наявність правил :focus-visible у фінальних артефактах.

9) Чи потрібно кільце фокусу на кожному елементі?

Лише на елементах, що можуть отримати фокус: інтерактивні контролі та все, що ви зробили фокусованим (посилання, кнопки, інпути, кастомні віджети з tabindex).
Не робіть випадкові контейнери фокусованими лише щоб «збільшити подібність до hover». Це створює шум і втому від табів.

10) Що, якщо дизайн хоче кастомний стиль фокусу для кожного компонента?

Добре. Обмеження — видимість і послідовність. Тримайте базове кільце як fallback, а потім покращуйте. Як тільки кастомні стилі почнуть видаляти кільце,
ви повертаєтеся в зону інцидентів.

Висновок: наступні кроки, які можна випустити цього тижня

Доступні стани фокусу — це не «додаткова опція». Це базова інфраструктура взаємодії. Ставтеся до них так само, як до TLS: визначіть базу, забезпечте її й не дозволяйте випадковим компонентам
відмовлятися через те, що комусь не подобається, як це виглядає на скріншоті.

Практичні наступні кроки:

  1. Реалізуйте базове кільце :focus-visible з використанням outline із зсувом, плюс опціональна аура для естетики.
  2. Додайте skip-посилання в базовий лейаут і уніфікуйте <main id="main" tabindex="-1"> на сторінках.
  3. Приберіть глобальне придушення фокусу, якщо ви не замінили його правильно.
  4. Підлатайте компоненти, що перекривають focus-visible, і додайте тести, щоб їх контролювати.
  5. Запустіть швидкий план діагностики, коли хтось скаже «з клавіатурою щось не так». Зазвичай це не «щось дивне», а поламка.
← Попередня
Консоль Proxmox не відкривається (SPICE/noVNC): де ламається і як виправити
Наступна →
Чому ігри хочуть один CPU, а рендеринг — інший

Залишити коментар