Доступность по уровням: что обязательно сделать в любом проекте за день
За семь лет фронта я видела одну и ту же сцену много раз: на ретро поднимается тема доступности, кто-то говорит «нам нужен полноценный аудит WCAG AA», бэклог пухнет на три спринта вперёд, а через полгода ничего не сделано. Потому что задача в таком виде — слишком большая. Её нельзя «сделать», её можно только бесконечно делать.
В моей дизайн-системе мы пошли другим путём. Разделили доступность на три уровня по стоимости. Уровень 0 — базовое, что заводится за день силами одного фронта. Уровень 1 — то, что требует пары спринтов и согласования с продуктом. Уровень 2 — глубокий аудит с привлечением людей с инвалидностью. Эта статья — про уровень 0. То, что должно быть в любом проекте, который ты ведёшь.
Зачем это вообще нужно
У меня нет миссии «спасти мир для людей с инвалидностью». У меня есть прагматика. Базовая доступность даёт три вещи: интерфейс перестаёт ломаться у пользователей с клавиатурным управлением, страницы лучше работают с автотестами, и юристы перестают писать гневные письма. В России пока на это редко подают в суд, но в ЕС и США — регулярно.
Уровень 0 — это про то, чтобы сайтом можно было пользоваться без мыши и чтобы скринридер хотя бы не врал. Не идеал, но граница, ниже которой нельзя.
Чек-лист уровня 0
1. lang на html
Самое глупое и самое забываемое. Без атрибута lang на <html> скринридер не понимает, на каком языке читать. Английский голос на русском тексте — это пытка.
<html lang="ru">В SPA на React/Vue это часто прибито к статичному index.html, и про него забывают, когда добавляют переключатель языка. Если у тебя multilang, обновляй атрибут на роутинге.
2. Title страницы должен меняться
В классическом MPA это бесплатно, в SPA — нет. Если у тебя пять разделов, и на всех в шапке одно и то же «Мой проект», скринридер на каждом переходе озвучивает одинаковое название. Пользователь теряется.
Решений два: либо react-helmet-async/@vueuse/head, либо ручное document.title = ... в эффекте на роуте. В Astro и Next.js всё уже встроено в head компонента страницы.
3. Видимый focus
Я писала отдельную статью про :focus-visible, но базовое правило коротко: не убирай outline. Если убрал — обязательно нарисуй замену. Самый глупый антипаттерн, который я встречала в продакшне:
*:focus { outline: none; }Это значит, что человек, перемещающийся по странице через Tab, не видит, где он находится. Если такая строчка есть в проекте — удали её прямо сейчас, не дочитывая статью.
:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
border-radius: 4px;
}4. Кнопки — это button, ссылки — это a
Признак фронтендера, который ещё не разбирался с доступностью: на странице пять div с обработчиками клика. Они не работают с клавиатуры, не озвучиваются скринридером как интерактивные, и в них нельзя попасть Tab'ом.
Правило простое:
- Действие на текущей странице (открыть модалку, переключить вкладку, отправить форму) —
<button type="button">. - Переход на другой URL —
<a href="...">.
Если очень хочется div, потому что «дизайнер сказал», — это плохой дизайнер. Можно взять button и через CSS сделать его внешне любым.
5. Все картинки имеют alt
Не «не забудь alt», а буквально каждая. Если картинка несёт смысл — описание этого смысла. Если декоративная — пустая строка alt="". Не отсутствие атрибута, а именно пустая строка. Без alt вообще скринридер озвучит имя файла: «slash-assets-slash-banner-final-final-version-two-png». Это позор.
<img src="/team.jpg" alt="Команда из шести разработчиков на ретроспективе">
<img src="/decoration-flourish.svg" alt="">SVG как иконки — отдельная история. Если иконка только декоративная: aria-hidden="true". Если несёт смысл (типа «удалить» рядом с пустой кнопкой): role="img" и aria-label, либо рядом текст, доступный скринридеру через <span class="sr-only">.
6. Контраст для текста
4.5:1 для основного текста, 3:1 для крупного (от 18pt или 14pt жирного). Проверь все цвета, которые есть в проекте: основной текст, ссылки, плейсхолдеры в инпутах, бордеры активных состояний. Самое распространённое — серенький текст «hint» поверх белого фона с контрастом 2.8:1. Ставится дизайнером, проходит ревью, попадает в прод, а пожилой пользователь не может прочитать подсказки в форме регистрации.
Быстрая проверка — через DevTools. В Chrome открой инспектор, ткни в текст, в панели Styles рядом с color увидишь индикатор контрастности.
7. Skip link на главные блоки
Если у тебя в шапке двадцать ссылок, пользователь клавиатуры, который пришёл прочитать статью, должен пройти через все двадцать. Это бесит. Решение — невидимая ссылка в начале body, которая показывается на фокус:
<a href="#main" class="skip-link">Перейти к содержимому</a>
<header>...</header>
<main id="main">...</main>.skip-link {
position: absolute;
left: -9999px;
}
.skip-link:focus {
position: static;
display: inline-block;
padding: 8px 16px;
background: var(--color-bg);
}8. Формы: каждый input связан с label
Не плейсхолдер вместо лейбла. Не «как-то рядом текст лежит». Связь либо через for/id, либо обёрнутый input внутри label.
<label for="email">Email</label>
<input id="email" type="email" name="email">Без этой связи скринридер не понимает, какой инпут что значит. Касание мобильного по тексту лейбла не фокусирует поле. Ошибки валидации — отдельная история, про неё уровень 1.
9. Heading-структура без дыр
На странице должен быть один h1. Дальше — иерархия без пропусков уровней. Если идёт h2, потом сразу h4, скринридер сообщает об ошибке структуры. Пользователь, который перемещается по заголовкам командой «следующий заголовок», теряется.
Часто разработчики используют h2-h6 ради размера шрифта. Это неправильно: семантический уровень и визуальный размер — разные вещи. Хочешь маленький заголовок, но второго уровня — пиши <h2 class="text-sm">.
10. Landmarks: header, main, nav, footer
Завернуть страницу в семантические теги — две минуты работы. Эффект — навигация скринридера через rotor (на VoiceOver — Ctrl+Option+U), которая позволяет прыгать по областям страницы.
<body>
<header>...</header>
<nav aria-label="Главная">...</nav>
<main>...</main>
<footer>...</footer>
</body>Если на странице несколько nav (главная и в подвале) — у каждого должен быть свой aria-label. Иначе скринридер их не различит.
Как этого добиться за день
В небольшом проекте этот чек-лист действительно проходится за рабочий день. Я обычно делаю так:
- Утром — линтеры. Установить
eslint-plugin-jsx-a11yдля React илиeslint-plugin-vuejs-accessibility. Добавить в CI. Они закрывают пункты 4, 5, 8, 9 автоматически — не пропустят div с onClick без role, картинку без alt, форму без лейбла. - До обеда — пройтись Tab'ом по всем основным сценариям. На каждый interactive element должен быть видимый фокус, переход должен идти в логичном порядке, ничего не должно «застревать».
- После обеда — открыть Chrome DevTools, на вкладке Issues включить Accessibility issues. Вкладка Lighthouse — прогнать audit. Не идеальный, но базовые проблемы покажет.
- К вечеру — добавить skip link, проверить landmarks, починить контрастность.
Это уровень 0. Дальше начинается уровень 1 — глубокая работа с aria, состояниями ошибок в формах, динамическими live-регионами для скринридеров. Но без уровня 0 туда лезть бессмысленно.
Что не делать
Самая частая ловушка — добавлять aria-label на всё подряд, надеясь, что это «улучшит доступность». Нет. ARIA должна дополнять там, где не хватает семантики, а не заменять её. <button aria-label="Закрыть">X</button> — нормально (текст внутри неинформативен). <a aria-label="Перейти на страницу профиля">Профиль</a> — лишний шум, ссылка и так озвучится «Профиль, ссылка».
Вторая ловушка — пытаться сразу пройти WCAG AAA. Это уровень для государственных сайтов и медицинских приложений. В обычном продукте AA достаточно, и до неё путь не такой долгий, если базовые вещи закрыты сразу.
Уровень 0 — это билет к разговору о доступности. Без него любые дискуссии про aria-live и keyboard traps бесполезны. С ним — у тебя есть фундамент, на котором можно строить дальше.