Чистая вёрстка: доступность (a11y) как обязательная часть интерфейса, а не «потом допилим»
Разберём фокус, aria-атрибуты, семантику, клавиатурную навигацию и практические правила, которые улучшают UX для всех.
Содержание
Чистая вёрстка: доступность (a11y) как обязательная часть интерфейса, а не «потом допилим»
Доступность (a11y, accessibility) — это не набор «магических атрибутов» и не чекбокс для отчёта. Это свойство интерфейса, которое проявляется во всех этапах: от семантики разметки до логики фокуса и корректных реакций на клавиатуру. И самое важное: доступность почти всегда ломается не на стадии “добавить aria”, а на стадии проектных решений — когда UI собирают из div и span, игнорируя смысл элементов, а затем пытаются «залатать» поведение.
В этой статье разберём, как строить чистую вёрстку так, чтобы доступность была не «потом допилим», а фундаментальным качеством. Мы поговорим о семантике, роли и назначении aria-атрибутов, навигации с клавиатуры, типичных ошибках, которые встречаются в реальных интерфейсах, и практических правилах, которые помогут улучшить UX для всех пользователей — включая тех, кто полагается на скринридеры, управляет страницей клавиатурой или испытывает трудности со зрением и вниманием.
Почему a11y нельзя откладывать
Есть устойчивый миф: «сначала сделаем интерфейс, а потом прикрутим доступность». На практике это почти всегда приводит к трём проблемам.
1) Доступность — это структура, а не косметика
Семантика — основа того, как ассистивные технологии интерпретируют интерфейс. Если мы построили кнопку не как кнопку, а как div с обработчиком клика, то это нарушает базовую модель взаимодействия. В таком случае aria-атрибуты будут работать не так, как ожидается, а поведение придётся воспроизводить вручную.
2) Поведение сложнее, чем кажется
Клавиатурная навигация, порядок фокуса, видимость фокуса, корректные состояния (например, aria-expanded), работа модалок/всплывающих подсказок — всё это требует согласованной логики. Чем позже заняться этим, тем больше мест придётся переделывать: верстка, JS-события, стили, тесты.
3) “Латки” часто ухудшают UX
Неправильные aria-роли и атрибуты могут сделать интерфейс хуже: скринридеры начнут объявлять не то, что нужно, или будут дублировать текст, либо пользователь застрянет в некорректном контуре фокуса.
Вывод простой: доступность должна быть в дизайне и в техническом исполнении с самого начала. Дальше — конкретика.
Семантика: основа без aria
Начнём с того, что в большинстве случаев aria не нужен вообще. Правильная семантика — это “естественный язык” для браузера и ассистивных технологий.
Используйте нативные элементы управления
Кнопка должна быть кнопкой:
<button type="button">Сохранить</button>
Ссылка — ссылкой:
<a href="/profile">Профиль</a>
Поле ввода — input, select или textarea, а не их имитация:
<label for="email">Email</label>
<input id="email" type="email" name="email" />
Причина практическая: нативные элементы автоматически поддерживают:
- клавиатурное взаимодействие (Enter/Space, таб-навигацию),
- корректное объявление экранными дикторами,
- доступные имена (accessible name) по правилам браузера,
- базовые состояния и “метки” (например,
checked,disabled).
Не заменяйте семантику контейнерами
Типичный анти-паттерн:
<div role="button" tabindex="0" onclick="...">Сохранить</div>
Иногда так делают, но почти всегда правильнее пересобрать в нативный button. Если это невозможно (например, сложная разметка), тогда aria и поведение должны быть выстроены аккуратно — но это исключение, а не правило.
Заголовки и структура документа
Скринридеры используют структуру документа: заголовки, списки, разделы. Проблема возникает, когда интерфейс “собирают” из случайных div без h1-h6. Даже если визуально всё выглядит ок, доступные технологии будут читать страницу иначе, и пользователь может потеряться.
Практическое правило: если в макете есть визуальные уровни заголовков — перенесите их в h-теги.
ARIA: когда нужен и когда вреден
ARIA (Accessible Rich Internet Applications) — это механизм, который добавляет описания и роли элементам. Но в идеале он дополняет нативное поведение, а не заменяет его.
Главная философия: “сначала нативное”
Если вы можете получить корректное поведение без aria — не добавляйте aria.
Например, у поля ввода есть label — добавлять aria-label не нужно. А если нужно скрыть декоративный элемент — используйте aria-hidden="true" или role="presentation" (в зависимости от ситуации), но не “на всё подряд”.
Accessible name: что и как объявляется
Скринридер обычно объявляет “имя” элемента. Оно формируется из:
- текста в
<label>, aria-label/aria-labelledby,altу изображений,- иногда из содержимого элемента.
Практический пример для кнопки с иконкой:
<button type="button" aria-label="Открыть настройки">
<svg aria-hidden="true" ...>
...
</svg>
</button>
Здесь aria-label нужен именно потому, что видимый текст отсутствует. Но если текст есть — лучше использовать его напрямую.
aria атрибуты для динамики: aria-expanded, aria-controls, aria-live
ARIA особенно полезен для динамических компонентов:
Складной блок (accordion)
<button
type="button"
aria-expanded="false"
aria-controls="panel-1"
id="accordion-1"
>
Подробнее
</button>
<div id="panel-1" role="region" aria-labelledby="accordion-1" hidden>
Контент...
</div>
При раскрытии:
- меняем
aria-expanded="true", - убираем
hidden, - корректно фокусируем элемент/перемещаем фокус по UX-правилам.
Область live для уведомлений
<div role="status" aria-live="polite" aria-atomic="true">
Данные сохранены
</div>
Это позволяет объявить сообщение экранному диктору, не требуя ручного “обновления страницы”.
Ошибки с aria: самые частые
-
Неправильные роли
Например,role="button"без полноценного keyboard support. Роль — это обещание поведения. Если обещание не выполняется, UI становится проблемным. -
Дублирование имени
Если у кнопки есть текст, но вы добавилиaria-label, экранный диктор может объявлять два разных имени или путать пользователя. -
aria-live “на всё”
Слишком активные live-области создают “информационный шум”. Сообщайте только то, что важно. -
Отсутствие синхронизации состояния
Еслиaria-expandedне меняется вместе с видимым состоянием — пользователь с ассистивными технологиями будет видеть другой “мир”, чем вы показываете визуально.
Клавиатурная навигация: от таба до логики поведения
Клавиатура — базовый тест доступности. Если интерфейс нельзя использовать без мыши, это не “почти доступно”, а скорее всего недоступно для части пользователей.
1) Порядок табуляции должен быть предсказуемым
Естественный порядок задаётся DOM. Если вы меняете порядок визуально (например, с помощью flex/grid reorder) — следите, чтобы таб-навигация не превращалась в случайную прогулку.
Практическое правило: используйте CSS перестановки (order) реже или очень осознанно. Если это необходимо — выстраивайте порядок focus вручную, но это усложняет поддержку и тестирование.
2) Обязательно есть видимый focus
Частая ошибка — “обнулить” стили фокуса в CSS. В итоге пользователь клавиатуры не понимает, где он находится.
Не надо “красить красиво любой ценой”, но focus outline должен быть видимым.
Пример безопасного поведения:
:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 2px;
}
focus-visible помогает избежать случайного outlines при клике мышью, но сохраняет видимость фокуса для клавиатуры.
3) Обрабатывайте клавиатурные события, если вы не используете нативные элементы
Когда вы всё же создаёте кастомный контрол (например, список вариантов, который визуально похож на кнопки), нужно обеспечить:
- доступ к элементу (tabindex),
- реакцию на Enter/Space (если это “кнопочное” поведение),
- корректную работу со
role.
Однако лучше путь — избегать кастомных кнопок поверх div и использовать button, a, select.
Если же кастом неизбежен, то минимальный “честный” каркас:
<div
role="button"
tabindex="0"
id="custom-btn"
>
Кастомная кнопка
</div>
<script>
const el = document.getElementById('custom-btn');
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
el.click();
}
});
</script>
Но всё равно это хуже нативного button: нативные элементы дадут больше “из коробки”.
4) Модалки и диалоги: фокус и возврат
Модальное окно должно:
- получать фокус при открытии,
- “держать” фокус внутри (focus trap),
- возвращать фокус на элемент-переключатель при закрытии,
- корректно закрываться Escape (обычно ожидаемо пользователями).
Здесь не приводим “универсальную библиотеку в вакууме”, но общая схема такая:
- Сохраняете ранее сфокусированный элемент.
- При открытии модалки переносите фокус на первый интерактивный элемент.
- Перехватываете Tab внутри.
- При закрытии возвращаете фокус назад.
Если вы делаете это позже, часто рушится поведение: кнопки закрытия остаются недостижимыми с клавиатуры или фокус “улетает” под оверлей.
Формы и ошибки ввода: доступность как часть UX
Формы — самый частый участок, где a11y реально влияет на конверсию и качество. Здесь важны не только роли, но и коммуникация ошибок.
Правильные label и связность
<label for="password">Пароль</label>
<input id="password" type="password" name="password" />
Если label не может быть текстовым рядом — используйте aria-label или aria-labelledby, но лучше дизайн подстроить под label (это проще для поддержки и читаемости).
Сообщения об ошибках: aria-describedby
Когда есть ошибка, желательно:
- показать текст ошибки визуально,
- объявить ошибку для ассистивных технологий,
- связать ошибку с полем.
Пример:
<label for="email">Email</label>
<input id="email" type="email" name="email" aria-describedby="email-error" />
<div id="email-error" role="alert" hidden>
Похоже, email введён с ошибкой
</div>
При валидации:
- снимаем
hidden, - убеждаемся, что текст ошибки появляется и связан через
aria-describedby.
role="alert" даёт приоритетное объявление. Иногда достаточно aria-live="polite", но для критичных ошибок чаще используют именно alert.
Не используйте placeholder как label
Placeholder может исчезать при вводе, он не считается полноценной меткой. aria-label не всегда спасает, потому что placeholder зачастую используется как “единственный текст” для идентификации поля — а это плохо для многих сценариев.
Практические правила чистой верстки с a11y-качеством
Соберём в “рабочий чек-лист”, который удобно применять в разработке.
H1: мыслите семантикой, не контейнерами
- Заголовки —
h1-h6. - Списки —
ul/ol/li. - Ссылки —
a. - Кнопки —
button. - Форма —
form,label,inputи т.д.
H2: не дублируйте семантику и не “переписывайте” нативное
- Если элемент уже делает нужное поведение — не добавляйте aria.
- Если добавляете role/aria — проверьте соответствие поведения.
H3: фокус — это UX
- Не отключайте outline.
- Делайте
:focus-visible. - Проверяйте весь сценарий “только клавиатура”.
H4: dynamic state должен быть синхронизирован с aria
aria-expanded↔ фактическое открытие/закрытие.aria-controls↔ реально существующий id.- Ошибки/успехи ↔ live/alert сообщения.
H5: тестируйте не только в одном браузере
ARIA и фокус — зона реальной совместимости, особенно в кастомных компонентах. Минимальный набор проверок:
- Chrome + NVDA / экранный диктор,
- Firefox + NVDA,
- Safari (хотя бы базовые сценарии),
- мобильные сценарии (где применимо).
Как внедрять доступность в процесс разработки
Чтобы “a11y потом” не превращалось в “a11y никогда”, нужна дисциплина. Это не только про код, но и про то, как вы проектируете.
Автоматизированный аудит — не замена, а стартовая точка
Инструменты (линтеры, проверки, расширения) хорошо ловят:
- отсутствующие атрибуты,
- противоречия между ролью и типом элемента,
- пустые
href, - некоторые проблемы с контрастом (если инструмент поддерживает).
Но они не проверяют:
- логический порядок фокуса,
- корректные клавиатурные сценарии,
- смысл доступных имён в конкретном контексте.
Минимальные ручные тесты, которые стоит сделать привычкой
- Отключите мышь и пройдите страницу табом.
- Откройте каждый интерактивный компонент клавиатурой.
- Проверьте Esc/Enter/Space там, где ожидается.
- Откройте скринридер и убедитесь, что “имя” и состояние объявляются осмысленно.
- Проверьте формы: ошибки объявляются и привязаны к полям.
Документируйте паттерны компонентов
Самая экономная практика — сформировать библиотеку “как мы делаем модалки”, “как мы делаем тулы/меню”, “как мы делаем select-like контролы”. Тогда доступность не зависит от героизма конкретного разработчика.
Практический пример: доступный выпадающий список
Рассмотрим простой компонент: выпадающий список, который открывается кнопкой и показывает набор вариантов. Ошибка многих реализаций — “неправильная роль” и некорректный focus.
Нативный вариант часто проще, но допустим кастом:
<div>
<button
type="button"
id="dd-btn"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="dd-list"
>
Выберите язык
</button>
<ul
id="dd-list"
role="listbox"
aria-labelledby="dd-btn"
tabindex="-1"
hidden
>
<li role="option" aria-selected="true" tabindex="-1">Русский</li>
<li role="option" aria-selected="false" tabindex="-1">English</li>
<li role="option" aria-selected="false" tabindex="-1">Deutsch</li>
</ul>
</div>
<script>
const btn = document.getElementById('dd-btn');
const list = document.getElementById('dd-list');
const options = Array.from(list.querySelectorAll('[role="option"]'));
function open() {
btn.setAttribute('aria-expanded', 'true');
list.hidden = false;
list.focus(); // фокус уходит в компонент
}
function close() {
btn.setAttribute('aria-expanded', 'false');
list.hidden = true;
btn.focus(); // возврат фокуса
}
btn.addEventListener('click', () => {
const isOpen = btn.getAttribute('aria-expanded') === 'true';
isOpen ? close() : open();
});
btn.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
open();
}
});
list.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
close();
return;
}
const activeIndex = options.findIndex(opt => opt.getAttribute('aria-selected') === 'true');
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = Math.min(activeIndex + 1, options.length - 1);
options[activeIndex].setAttribute('aria-selected', 'false');
options[next].setAttribute('aria-selected', 'true');
options[next].focus();
}
if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = Math.max(activeIndex - 1, 0);
options[activeIndex].setAttribute('
Комментарии
Пока нет комментариев