Когда говорят про доступность сайта, в первую очередь обычно вспоминают про скринридеры. На деле есть аудитория шире и проще для диагностики — пользователи, которые ходят по сайту с клавиатуры. Кто-то так делает быстрее, чем тянется к мыши; кто-то использует switch-устройство, голосовое управление вроде Dragon, ножную педаль или сторонний адаптивный контроллер; кто-то слепо ведёт скринридер. У всех них общий минимальный язык взаимодействия — клавиши Tab, Shift+Tab, Enter, Space и стрелки.

Хорошая новость: фундамент клавиатурной доступности закрывается чистым HTML и CSS, без единой строчки JavaScript. Разберём, что фокусируется само, как управлять порядком обхода, как стилизовать индикатор фокуса по WCAG и как не сломать всё это раскладкой через flex и grid.

Сначала — самая короткая проверка

Перед любой теорией: отключи мышь и трекпад и пройди по своему сайту только с клавиатуры. Открой главную, дотыкайся Tab’ом до меню, до карточек, до формы, до футера. Если в каком-то месте фокус «потерялся» (не видно, где он сейчас), застрял (Tab никуда не ведёт) или поехал не туда (после третьей карточки прыгнул в подвал), — это и есть твой список багов доступности на ближайший час. Никакие линтеры и Lighthouse-аудиты не дадут такого ясного отчёта, как пятиминутный обход вручную.

Что фокусируется по умолчанию

Браузер сам включает в порядок обхода Tab вполне конкретный список элементов. Запомнить его легко — это то, ради чего HTML вообще придумали:

  • <a> с атрибутом href;
  • <button>;
  • <input>, <select>, <textarea>;
  • <summary> (открывашка для <details>);
  • аудио/видео с атрибутом controls;
  • любые элементы с contenteditable.

Клавиши работают так. Tab переводит фокус на следующий focusable-элемент, Shift+Tab — на предыдущий. Enter «нажимает» ссылку, кнопку и <summary>. Space делает то же самое для кнопки и чекбокса, но не для ссылки — маленькая, но регулярно ломающая интерфейс деталь, если кнопку подменили на <a> без href. Стрелки ходят между радио-кнопками внутри одной группы и между опциями в <select>.

Главный практический вывод: чем больше вёрстки сделано на семантических тегах, тем меньше работы по доступности. Заменили <button> на <div onclick> — потеряли фокус, потеряли Enter/Space, потеряли роль для скринридера. Чинить это потом дороже, чем сразу написать <button>.

Атрибут tabindex: три режима, и два из них опасные

Атрибут tabindex позволяет включать в Tab-порядок то, что туда по умолчанию не входит, и наоборот. У него три осмысленных значения, и относиться к ним надо по-разному.

tabindex="0" — включить в обход

Делает элемент достижимым с клавиатуры в порядке, заданном DOM. Уместен редко — для контейнеров с прокруткой, где пользователю реально надо встать фокусом и пролистать содержимое стрелками: широкая таблица, длинный блок кода с горизонтальным скроллом, карусель.

<pre class="code-block" tabindex="0">
  <code>... много длинных строк ...</code>
</pre>

Что не нужно делать — раскидывать tabindex="0" по карточкам, плиткам и просто «на всякий случай». Каждый лишний пункт в Tab-обходе — ещё одна остановка, через которую пользователь обязан пройти, чтобы добраться до нужной кнопки. Если на элемент не должны кликать — ему нечего делать в порядке обхода.

tabindex="-1" — убрать из обхода, но оставить программный фокус

Это инструмент авторов компонентов. Элемент с tabindex="-1" пропускается клавишей Tab, но на него можно поставить фокус из JavaScript через element.focus(). Классический пример — компонент табов: между ними должны переключать стрелки, а не Tab, чтобы пользователь не тыкал клавишу пять раз ради перехода с первой вкладки на пятую.

<ul role="tablist">
  <li role="presentation">
    <button role="tab" id="tab-overview" aria-selected="true">Обзор</button>
  </li>
  <li role="presentation">
    <button role="tab" id="tab-specs" tabindex="-1">Характеристики</button>
  </li>
  <li role="presentation">
    <button role="tab" id="tab-reviews" tabindex="-1">Отзывы</button>
  </li>
</ul>

Активный таб остаётся в обходе (никакого tabindex или tabindex="0"), остальные — tabindex="-1". Tab из любого таба ведёт сразу в содержимое панели, а не в соседние вкладки. Соглашение: значение всегда пишут -1, хотя технически любое отрицательное число даёт тот же эффект.

Положительные значения — никогда

Конструкция tabindex="3" переопределяет порядок обхода: сначала браузер обойдёт все элементы с положительными значениями по возрастанию, и только потом — нативные и tabindex="0". На практике это означает: достаточно одной кнопки с tabindex="1" где-то посреди формы, чтобы Tab вёл себя необъяснимо. Это прямое нарушение WCAG 2.4.3 (Focus Order). Лечится только удалением.

Атрибут inert — временно «выключить» кусок страницы

Свежий булевый атрибут inert делает поддерево DOM полностью недостижимым: оно теряет фокус, его не видят скринридеры, по нему не проходит Tab. Полезно для фоновой части страницы, когда сверху открыта модалка, для слайдов карусели вне окна просмотра, для черновиков офлайн-редактора.

<main inert>
  <!-- модалка открыта, контент позади неё временно недоступен -->
</main>
<dialog open>...</dialog>
Поддержка браузерами
chrome
Chrome
102
firefox
Firefox
112
edge
Edge
102
safari
Safari
15.5
opera
Opera
88

Индикатор фокуса — это закон, а не декорация

Самое распространённое требование на код-ревью со стороны дизайнеров — убрать «уродскую синюю рамку» вокруг кнопок после клика. Самое распространённое решение из 2010-х — outline: none. С точки зрения клавиатурного пользователя это эквивалент того, как если бы курсор мыши исчез у обычного пользователя: фокус по-прежнему где-то есть, но где — неизвестно.

WCAG 2.4.7 (Focus Visible) требует, чтобы у любого интерактивного элемента индикатор фокуса был видим хотя бы в каком-то режиме. Более свежий критерий 2.4.11 (Focus Appearance) уточняет геометрию: минимальная толщина обводки — 2 пикселя, контрастность фокус-состояния против не-фокус-состояния — 3:1, контраст индикатора против соседних цветов — тоже 3:1.

Удобный масштабируемый рецепт — завести CSS-переменные и собрать индикатор через них:

:is(a, button, input, textarea, summary) {
  --outline-size: max(2px, 0.08em);
  --outline-style: solid;
  --outline-color: currentColor;
}

:is(a, button, input, textarea, summary):focus {
  outline:
    var(--outline-size)
    var(--outline-style)
    var(--outline-color);
  outline-offset: var(--outline-offset, var(--outline-size));
}

Что тут происходит. max(2px, 0.08em) гарантирует минимум 2 пикселя, но даёт обводке расти вместе с размером шрифта — крупная кнопка получит более толстый контур. currentColor избавляет от ручного подбора цвета на тёмной и светлой темах: индикатор автоматически берёт цвет текста элемента. outline-offset добавляет зазор между обводкой и границей — полезно, когда у кнопки скруглённые углы и обводка вплотную смотрится грязно.

Если по требованию дизайнера фокус-обводка должна быть невидимой в определённом состоянии — пишут не outline: none, а outline-color: transparent. Разница важна для пользователей режима высокой контрастности Windows: none убирает обводку полностью, и фокус становится невидимым; transparent сохраняет геометрию обводки, и ОС подставляет свой системный цвет вместо «прозрачного».

:focus, :focus-visible и :focus-within

Три фокус-псевдокласса делают разные вещи и нужны в разных местах. Путать их обходится дорого.

:focus срабатывает всегда, когда элемент в фокусе — независимо от того, дошли до него клавишей или ткнули мышкой. Поэтому если оставить только :focus, обводка появится и после обычного клика по кнопке, и это раздражает пользователей мыши, которым она не нужна.

:focus-visible применяет стили только когда браузер считает, что подсветка фокуса «заслужена»: при навигации с клавиатуры, при фокусе из JS, в режиме высокой контрастности. После мышиного клика по кнопке — не срабатывает. Это именно тот псевдокласс, который должен использоваться для индикатора фокуса в современных проектах.

button:focus-visible {
  outline: max(2px, 0.08em) solid currentColor;
  outline-offset: 2px;
}

Тонкий момент: эвристика :focus-visible по-разному ведёт себя для разных элементов. Для <button>, <a>, чекбоксов и радио — обводка появляется только при клавиатурном фокусе. А вот для <input type="text">, <textarea> и <select> подсветка показывается даже при мышином клике — и это правильно, потому что пользователь начинает вводить текст с клавиатуры, и ему важно видеть, какое поле сейчас активно. Это не баг, а заложенное поведение.

Для очень старых браузеров (Safari до 15.4) пригодится фолбек через feature query:

@supports selector(:focus-visible) {
  *:focus {
    outline: none;
  }

  *:focus-visible {
    outline: max(2px, 0.08em) solid currentColor;
    outline-offset: 2px;
  }
}

В браузере без поддержки :focus-visible сработает обычное правило с :focus снаружи блока @supports; в современных — обнулится дефолтный :focus (чтобы не было двойной обводки) и применится :focus-visible.

Поддержка браузерами
chrome
Chrome
86
firefox
Firefox
85
edge
Edge
86
safari
Safari
15.4
opera
Opera
72

:focus-within срабатывает на элементе, когда в фокусе он сам или любой его потомок. Полезно для контейнеров — подсветить целиком форму, в которой сейчас редактируется одно из полей, или раскрыть подсказку у карточки, когда пользователь дошёл фокусом до её кнопки.

.field {
  display: grid;
  gap: 0.5rem;
  padding: 0.75rem;
  border-radius: 0.5rem;
}

.field:focus-within {
  background-color: #f3f0ff;
  outline: 1px solid #6d28d9;
}
Поддержка браузерами
chrome
Chrome
60
firefox
Firefox
52
edge
Edge
79
safari
Safari
10.1
opera
Opera
47

Подводные камни раскладки

Tab всегда идёт по дереву DOM, в том порядке, в каком элементы записаны в HTML. CSS-раскладка визуально может поставить элементы как угодно — и тогда визуальный порядок и Tab-порядок начинают расходиться, ломая ориентацию.

order, grid-row, grid-column — визуально переставляют, в DOM нет

Свойства order во flex, явное позиционирование grid-row/grid-column или назначение через grid-template-areas рисуют элементы в нужных местах сетки, но порядок Tab их не видит. Картинка ниже — распространённый случай: визуально кнопка «Купить» стоит вверху карточки, но в DOM она внизу. Клавиатурный пользователь, дойдя до карточки, в первую очередь получит фокус на описание — не на главное действие.

.card {
  display: grid;
  grid-template-areas:
    "cta"
    "title"
    "description";
}

.card__cta { grid-area: cta; }       /* в DOM — последний */
.card__title { grid-area: title; }
.card__description { grid-area: description; }

Это прямой конфликт с WCAG 2.4.3 (Focus Order): порядок получения фокуса должен сохранять смысл и работоспособность. Если визуально пользователь читает сверху вниз, то и фокусом обходить нужно сверху вниз. Лечение простое и неинтересное: переставить элементы в HTML так, чтобы DOM-порядок совпадал с тем, что видит глаз. Свойство order и явные координаты в grid’е оставляем для микроперестановок, где они не меняют логику чтения.

display: contents отбирает фокус у потомков

Полезное на первый взгляд свойство display: contents убирает рендеринг бокса, но оставляет детей в дереве — они «всплывают» на уровень родителя для целей раскладки. Когда-то у этого свойства был известный баг: в большинстве браузеров фокусируемые потомки переставали быть фокусируемыми, потому что элемент-обёртка фактически выпадал из дерева доступности. К сегодняшнему дню баг исправили в Chrome и Firefox, а Safari подтянулся последним.

Практический вывод: display: contents работает, но если в проекте есть поддержка чуть более старых Safari (15.x), его лучше не применять к контейнерам, внутри которых живут кнопки, ссылки и формы. Перед использованием — всегда обход с клавиатуры, см. первый раздел.

Готовые паттерны без JS

Не каждый интерактивный компонент требует JavaScript. Браузер уже умеет два чрезвычайно полезных приёма — их регулярно переписывают вручную, хотя нативные работают лучше.

Аккордеон через details и summary

Связка <details>/<summary> — полноценный аккордеон из коробки. <summary> сам становится focusable, открывается Enter и Space, имеет роль для скринридера, состояние «открыт/закрыт» синхронизировано с атрибутом open. Из CSS остаётся только убрать дефолтный треугольник и добавить свой индикатор.

<details>
  <summary>
    <span>Что входит в доставку</span>
    <span class="indicator" aria-hidden="true"></span>
  </summary>
  <p>Курьер привозит заказ в течение дня...</p>
</details>
summary {
  list-style: none;             /* убираем дефолтный треугольник */
  cursor: pointer;
}

summary::-webkit-details-marker {
  display: none;                /* то же самое для Safari */
}

.indicator::before {
  content: "+";
}

details[open] .indicator::before {
  content: "\2212";             /* минус */
}

Декоративный плюсик/минус оборачивается в <span aria-hidden="true">, чтобы скринридер не озвучивал содержимое CSS-псевдоэлемента. Состояние раскрытия пользователь и так услышит из роли элемента.

Skip-link, чтобы пропустить шапку

В пользовательском обходе шапка типичного сайта — это десять-пятнадцать ссылок меню перед первой осмысленной кнопкой. Клавиатурный пользователь обязан проходить их на каждой странице. WCAG 2.4.1 (Bypass Blocks) требует механизм, чтобы пропускать повторяющиеся блоки — обычно его реализуют как невидимую по умолчанию ссылку, которая всплывает при первом нажатии Tab.

<body>
  <a class="skip-link" href="#main">К основному содержимому</a>
  <header>...</header>
  <main id="main" tabindex="-1">...</main>
</body>
.skip-link {
  position: absolute;
  inset-inline-start: 1rem;
  inset-block-start: 1rem;
  padding: 0.5rem 1rem;
  background: #111;
  color: #fff;
  border-radius: 0.25rem;

  /* убираем со страницы, но оставляем доступной для клавиатуры и скринридера */
  clip-path: inset(50%);
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.skip-link:focus {
  clip-path: none;
  width: auto;
  height: auto;
  overflow: visible;
}

Старый паттерн со скрытием через transform: translate(-9999px) работает, но у него есть тонкая болячка — в правом-направо контексте элемент всё равно остаётся в потоке и может провоцировать горизонтальный скролл на узких экранах. Современный приём со схлопыванием через clip-path: inset(50%) и нулевыми размерами лишён этой проблемы, и его же обычно используют для класса .sr-only — контента, видимого только для скринридеров.

Атрибут tabindex="-1" на <main> важен: без него ссылка визуально прокрутит страницу, но фокус останется в шапке, и следующий Tab вернёт пользователя в меню. С tabindex="-1" фокус прыгнет внутрь основной области, и обход продолжится оттуда.

Тултип на чистом CSS — на 80%

Подсказку при наведении и клавиатурном фокусе можно собрать без JS: button:hover + [role="tooltip"], button:focus + [role="tooltip"], [role="tooltip"]:hover — селекторы, которых хватит для показа и удержания тултипа над курсором. Чего без JS не сделать — закрывать тултип по Esc, как требует WCAG 1.4.13 (Content on Hover or Focus). Поэтому в продакшене чисто-CSS-тултип используют только для совсем декоративных подсказок; для всего важного нужен скрипт.

Чеклист «как не сломать клавиатуру»

  • Кнопки — <button>, ссылки — <a href>. Не подменяй <div>+onclick.
  • Никаких положительных tabindex в продакшене. tabindex="0" — редко и точечно, -1 — в компонентах с программной навигацией.
  • Индикатор фокуса есть на каждом интерактивном элементе. Минимум 2 пикселя, контраст 3:1.
  • Вместо outline: noneoutline-color: transparent, если очень нужно «спрятать».
  • Для подсветки клавиатурного фокуса — :focus-visible, не :focus.
  • DOM-порядок совпадает с визуальным; order и явные позиции в grid’е — только для микроперестановок.
  • В шапке есть skip-link к основному содержимому, цель ссылки имеет tabindex="-1".
  • Аккордеоны и раскрытия — через <details>/<summary>, пока не доказано обратное.
  • Раз в спринт — обход сайта с клавиатуры без мыши. Самый дешёвый и самый полезный аудит.

JavaScript добавляет к этому всему focus trap в модалках, скрытие тултипа по Esc, управление виртуальным фокусом в сложных компонентах — но это уже сверху. Базовая клавиатурная доступность — это аккуратный HTML и десяток правил CSS, которые легко прописываются один раз в дизайн-систему и больше не отвлекают.