Когда говорят про доступность сайта, в первую очередь обычно вспоминают про скринридеры. На деле есть аудитория шире и проще для диагностики — пользователи, которые ходят по сайту с клавиатуры. Кто-то так делает быстрее, чем тянется к мыши; кто-то использует 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>
Индикатор фокуса — это закон, а не декорация
Самое распространённое требование на код-ревью со стороны дизайнеров — убрать «уродскую синюю рамку» вокруг кнопок после клика. Самое распространённое решение из 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.
: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;
}
Подводные камни раскладки
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: none — outline-color: transparent, если очень нужно «спрятать».
- Для подсветки клавиатурного фокуса — :focus-visible, не :focus.
- DOM-порядок совпадает с визуальным; order и явные позиции в grid’е — только для микроперестановок.
- В шапке есть skip-link к основному содержимому, цель ссылки имеет tabindex="-1".
- Аккордеоны и раскрытия — через <details>/<summary>, пока не доказано обратное.
- Раз в спринт — обход сайта с клавиатуры без мыши. Самый дешёвый и самый полезный аудит.
JavaScript добавляет к этому всему focus trap в модалках, скрытие тултипа по Esc, управление виртуальным фокусом в сложных компонентах — но это уже сверху. Базовая клавиатурная доступность — это аккуратный HTML и десяток правил CSS, которые легко прописываются один раз в дизайн-систему и больше не отвлекают.
Комментарии (0)