Senior Frontend Architect, 10+ лет опыта построения production-проектов на Next.js. Contentful Certified Professional (2024). Специализация: React Server Components, headless eCommerce, инженерия Core Web Vitals.
В прошлом году я три дня аудировал чекаут с оценкой дизайна 4,8 из 5 в Figma. Визуально всё было плотно и аккуратно. Только вот в поле промокода нельзя было попасть табом: это был <div> с обработчиком клика, без tabindex и без роли. Пользователи скринридеров слышали «без метки» на каждом поле, потому что дизайнер использовал плейсхолдер вместо лейбла, а разработчик скопировал этот паттерн дальше. Сообщения об ошибках краснели, но программно с полями связаны не были. Кнопка «Оформить заказ» жила внутри кастомного компонента, который перехватывал Enter, но не Space. Ни один из этих багов не был виден тому, кто пользуется мышью.
У провалов доступности всегда одна и та же природа: они невидимы для большинства команды и полностью блокируют конкретную группу пользователей. В этом посте я разбираю, как скринридеры на самом деле парсят DOM, что требует WCAG 2.2 в терминах конкретной реализации, какие паттерны клавиатурной навигации ломаются чаще всего и как выстроить тестирование, которое ловит это раньше пользователей. Пишу с позиции фронтенд-инженера: на спецификацию ссылаюсь там, где это важно, но фокус на том, что реально ломается в продуктах и как это чинить.
Часть I - Кто использует ассистивные технологии и почему это важнее, чем просто соответствие требованиям
Рамка «доступность - это про инвалидность» упускает большую часть тех, кого она затрагивает. По оценке ВОЗ, 1,3 миллиарда человек, то есть 16% населения планеты, живут с той или иной формой инвалидности. Но спектр гораздо шире постоянных состояний: человек со сломанной рукой ходит по сайту с клавиатуры. Человек на ярком солнце нуждается в высоком контрасте. Человек после сотрясения не справляется с плотным и быстро меняющимся содержимым. Человек в шумном месте включает субтитры. Инклюзивный дизайн, который справляется с этими случаями, служит не только людям с инвалидностью: он даёт лучший интерфейс всем, кто оказался в неидеальных условиях.
- Пользователи скринридеров: В США около 7,6 миллиона человек с нарушениями зрения (Бюро переписи США). В мире ВОЗ насчитывает 285 миллионов. Доли скринридеров: JAWS (38%), NVDA (31%), VoiceOver на iOS и macOS (18%), TalkBack на Android (7%). Это не крайние случаи: NVDA бесплатен, VoiceOver идёт с каждым устройством Apple, TalkBack встроен в Android. Ваши пользователи уже ими пользуются.
- Только клавиатура: Двигательные нарушения затрагивают примерно 2 миллиона американцев, которые используют альтернативный ввод - переключатели, устройства sip-and-puff, айтрекинг. Всё это на уровне операционной системы эмулирует клавиатуру. Если клавиатурная навигация сломана, для этой группы недоступен весь продукт.
- Когнитивные особенности и трудности обучения: Дислексия затрагивает 15–20% населения, СДВГ - 8–10% взрослых. Обеим группам помогают ясная типографская иерархия, предсказуемая навигация, видимый фокус и возможность остановить или контролировать зависящее от времени содержимое. Эти же улучшения снижают когнитивную нагрузку и у нейротипичных пользователей в стрессовых ситуациях.
- Юридические риски: ADA в США, EN 301 549 в ЕС и Equality Act в Британии устанавливают требования к цифровой доступности. Число исков по ADA к сайтам превысило 4600 в 2023 году, плюс 42% год к году (UsableNet). Целевой уровень для соответствия в большинстве юрисдикций - WCAG 2.1 AA; актуальная рекомендация W3C - WCAG 2.2 AA.
- Влияние на бизнес: Совокупный располагаемый доход людей с инвалидностью и их ближайшего окружения оценивается в 13 триллионов долларов (Accenture). Сайты, которые валят базовые проверки, теряют этот сегмент. И практичнее: каждый провал WCAG - это провал юзабилити, который задевает круг шире, чем только людей с инвалидностью.
Часть II - Как скринридеры реально парсят DOM
Модель, которая снимает большинство ошибок с ARIA: скринридер читает не HTML, а дерево доступности. Это параллельная структура, которую браузер выводит из DOM и отдаёт через платформенные API (MSAA и UIA в Windows, NSAccessibility в macOS, AT-SPI в Linux). У каждого узла этого дерева четыре ключевых свойства: роль, имя, состояние и значение. Работая со скринридером, вы ходите по этому дереву, а не по DOM.
- Роль: Выводится из тега (
<button>даёт роль button,<nav>- navigation) либо переопределяется атрибутомrole(<div role='button'>). Роль говорит скринридеру, что это за элемент управления и каких взаимодействий от него ждать.<div>без роли - это generic-контейнер: в режиме чтения скринридер озвучит его текст, но в интерактивном режиме пользователь не найдёт его по горячим клавишам «B» (следующая кнопка) или «F» (следующее поле формы). - Имя: Доступное имя - это то, что скринридер произносит, когда фокус попадает на элемент. Источники в порядке приоритета:
aria-labelledby(ссылка на текст другого элемента),aria-label(явная строка), содержимое элемента (для кнопок и ссылок), связанный<label>(для полей формы), атрибутtitle(запасной вариант, ненадёжный). Плейсхолдер источником имени НЕ является: он исчезает при вводе и надёжно не озвучивается. Это самый частый баг с метками. - Состояние: Раскрыт ли элемент (
aria-expanded), отмечен ли (aria-checked), невалиден ли (aria-invalid), заблокирован (disabledилиaria-disabled), выбран (aria-selected). Состояние обязано обновляться программно при изменении: кастомный аккордеон, который визуально раскрылся, обязан переключитьaria-expandedна триггере. Изменения только на вид скринридеру не видны. - Режим чтения против интерактивного: У скринридеров два основных режима. В режиме чтения (по умолчанию у NVDA и JAWS при загрузке) стрелки линейно ходят по дереву доступности и озвучивают всё содержимое. В интерактивном режиме (режим форм) клавиши перехватывает сфокусированный элемент, а не скринридер. Переключение автоматическое: фокус на текстовом поле включает интерактивный режим,
Escapeвозвращает в чтение. Поэтому управляемые с клавиатуры элементы обязаны иметь нативную семантику:<div>сonclickрежим форм не включит, и взаимодействовать с ним не выйдет. - Виртуальный курсор против фокуса: В режиме чтения скринридер держит виртуальный курсор отдельно от клавиатурного фокуса браузера. Виртуальный курсор может стоять на любом элементе, даже нефокусируемом. Поэтому нельзя по CSS
:focusсудить о том, что скринридер читает прямо сейчас: это только позиция клавиатурного фокуса. Живые области ARIA (о них в седьмой части) - способ объявить изменение содержимого, не заставляя пользователя переходить к конкретному элементу.
Часть III - Клавиатурная навигация: паттерны, которые ломаются чаще всего
У клавиатурной навигации есть чёткая спецификация: ARIA Authoring Practices Guide (APG) описывает ожидаемое поведение клавиш для каждого типа виджета. Провалы, которые я вижу в продакшене, почти всегда идут от кастомных компонентов, где реализована часть поведения, но не всё, либо от управления фокусом, о котором просто не подумали.
- Порядок табуляции и порядок DOM: По умолчанию таб идёт по порядку исходников, а не по визуальной раскладке. CSS grid и flexbox позволяют переставлять элементы визуально, не трогая DOM, и тогда порядок чтения расходится с порядком табуляции. Правило: порядок в исходнике должен совпадать с задуманным порядком чтения. Если визуальной перестановки не избежать, можно применить
tabindex, но ручное управление им в сложной раскладке хрупко. - Ловушка фокуса в модалках: Когда модалка открыта, фокус обязан быть заперт внутри неё.
Tabдолжен ходить только по фокусируемым элементам модалки, а с последнего переходить на первый, а не убегать на страницу позади. При закрытии фокус обязан вернуться на элемент, который её открыл. Для этого нужно: перехватыватьTabиShift+Tabна keydown, собирать все фокусируемые потомки (button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])) и заворачивать переход по кругу. Библиотеки вродеfocus-trapделают это правильно; именно в самописных реализациях и заводятся баги. - Неправильное использование
tabindex:tabindex='0'делает элемент фокусируемым в порядке DOM - для кастомных интерактивных элементов, не фокусируемых нативно.tabindex='-1'делает элемент фокусируемым программно через.focus(), но убирает из порядка табуляции - для элементов, которые получают фокус кодом, например контейнеров модалок. Значенияtabindexбольше нуля - легаси-паттерн, который делает порядок непредсказуемым; использовать его не следует никогда. - Клавиши для кастомных виджетов: APG задаёт ожидаемые паттерны. Кастомный выпадающий список:
EnterилиSpaceоткрывает меню, стрелки ходят по опциям,Enterвыбирает,Escapeзакрывает и возвращает фокус на триггер. Кастомные табы: стрелки переключают вкладки внутри списка (паттерн roving tabindex),Tabуводит фокус в панель. Аккордеон:EnterилиSpaceпереключает. Реализовать половину хуже, чем не реализовывать вовсе: выпадающий список, который открывается поEnter, но требует мыши для выбора опции, - это клавиатурная ловушка. - Ссылки пропуска навигации: Первым фокусируемым элементом каждой страницы должна быть ссылка «Перейти к основному содержимому», которая перепрыгивает навигацию. Без неё клавиатурный пользователь на каждой загрузке проходит табом все пункты меню. Обычно ссылку прячут визуально и показывают по фокусу:
<a href='#main-content' class='sr-only focus:not-sr-only'>Перейти к основному содержимому</a>плюс<main id='main-content' tabindex='-1'>. Атрибутtabindex='-1'на<main>нужен, чтобы элемент мог принять программный фокус, не появляясь в порядке табуляции. - Видимость фокуса: Критерий WCAG 2.2 SC 2.4.11 (Focus Not Obscured, уровень AA) требует, чтобы получивший фокус элемент не был целиком скрыт липкой шапкой, баннером о куках или фиксированным оверлеем. Используйте CSS
scroll-margin-top, чтобы учесть высоту липкой шапки. Селектор:focus-visibleнацелен именно на клавиатурный фокус, а не на клик мышью: с ним можно дать чёткий индикатор, не мешая пользователям мыши. Штатное кольцо фокуса браузера остаётся правильным ответом со времён Chrome 86 и Safari 15.4; старый приёмoutline: none- прямой провал WCAG.
Часть IV - WCAG 2.2: критерии, которые реально встречаются в аудитах
В WCAG 2.2 семьдесят восемь критериев успеха на трёх уровнях соответствия (A, AA, AAA). Уровень AA - юридический стандарт в большинстве юрисдикций и практическая цель для большинства продуктов. Разбирать все семьдесят восемь нет смысла; ниже те, на которые приходится большая часть провалов в продакшен-аудитах, вместе с деталями реализации, которые действительно важны.
- SC 1.1.1 Нетекстовое содержимое (уровень A): У каждого изображения должна быть текстовая альтернатива. Для информативных
altописывает содержание и функцию. Для декоративных -alt='', то есть пустая строка, а не отсутствующий атрибут, чтобы скринридер их пропустил. Для функциональных (иконки-ссылки, иконочные кнопки)altилиaria-labelописывает действие, а не картинку. Типичный провал: иконочная кнопка без текста и безaria-label- скринридер объявляет «кнопка» без имени, пользоваться нечем. Для SVG-иконки внутри кнопки:aria-hidden='true'на иконке плюс видимый или визуально скрытый текст, либоaria-labelна кнопке. - SC 1.4.3 Контраст (уровень AA): Обычный текст требует контраста 4,5:1 к фону. Крупный (от 18pt или 14pt жирным) - 3:1. Элементы интерфейса - границы полей, контуры кнопок - 3:1 к соседним цветам. Частые провалы: светло-серый плейсхолдер, белый текст на светлом брендовом цвете, заблокированные элементы с недостаточным контрастом (WCAG 2.2 выводит по-настоящему заблокированные элементы из-под требования, но только если они действительно disabled, а не просто так стилизованы).
- SC 1.4.4 Масштабирование текста (уровень AA): Текст должен читаться при увеличении до 200% без потери содержимого и функциональности. Ломается, когда размеры шрифта заданы в
pxвместоrem(зум браузера меняет вьюпорт, а не пиксели), когда у контейнеров фиксированная высота и текст обрезается, или когда текст лежит в SVG, который не масштабируется. - SC 2.1.1 Клавиатура (уровень A): Вся функциональность должна работать с клавиатуры. Как ломается: кастомные интерактивные компоненты с обработчиками только под мышь. Каждому обработчику
clickна ненативном элементе нужен парныйkeydownнаEnterи часто наSpace. Нативные<button>и<a>делают это сами - это и есть главный аргумент в пользу нативных элементов против div-ов с ARIA. - SC 2.4.3 Порядок фокуса (уровень A): Фокус должен двигаться в порядке, который сохраняет смысл и работоспособность. Нарушается, когда модалки добавляются в
<body>без управления фокусом, когда динамическое содержимое вставляется перед текущей позицией фокуса без объявления или когда значенияtabindexсоздают нелогичную последовательность. - SC 2.4.7 Видимый фокус (AA) и SC 2.4.11 Фокус не перекрыт (AA, новый в 2.2): Фокус обязан быть видимым (не убирайте
outline), а сфокусированный элемент не должен целиком скрываться перекрывающим содержимым вроде липкой шапки. SC 2.4.12 (уровень AAA) идёт дальше: у сфокусированного элемента не должно быть перекрыто ничего. - SC 3.3.1 Определение ошибки (A) и 3.3.2 Метки (A): Когда обнаружена ошибка ввода, элемент с ошибкой должен быть определён, а сама ошибка описана текстом - не только цветом и не только иконкой. Сообщение обязано быть программно связано с полем через
aria-describedby(илиaria-errormessageвместе сaria-invalid='true'). Метка должна называть назначение поля. Плейсхолдер проваливает SC 3.3.2, потому что исчезает и в большинстве браузеров не проходит по контрасту. - SC 4.1.2 Имя, роль, значение (уровень A): У всех компонентов интерфейса имя, роль и текущее состояние должны определяться ассистивными технологиями. Это общий критерий для кастомных виджетов: каждый интерактивный компонент обязан отдавать свою роль (нативной семантикой или атрибутом
role), доступное имя и текущее состояние (aria-expanded,aria-selected,aria-checkedи так далее).
Часть V - Типичные production-ошибки и паттерн исправления
Это провалы, которые всплывают почти в каждом аудите доступности, что я делал на продакшен-фронтендах интернет-магазинов и SaaS. У каждого есть устойчивый паттерн исправления.
- Кнопки из
<div>и<span>:<div onClick={handler}>Click me</div>- ни роли, ни доступа с клавиатуры, ни фокуса. Лечение:<button type='button'>для всех интерактивных элементов, кроме навигационных ссылок. Если сменить элемент нельзя, добавьтеrole='button' tabindex='0' onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handler(e); }}. Но нативная<button>всегда лучше: она делает всё это сама, работает с отправкой формы и имеет нативное состояние disabled. - Иконочные кнопки без доступного имени:
<button><Icon /></button>объявляется как «кнопка» без метки. Лечение:aria-label='Закрыть диалог'на кнопке либо визуально скрытый текст<span class='sr-only'>Закрыть диалог</span>. На SVG-иконке поставьтеaria-hidden='true', чтобы её пропустили. Атрибутtitleосновной меткой быть не должен: он озвучивается непоследовательно и не появляется на тач-устройствах. - Поля без постоянных меток: Плейсхолдер вместо метки. Лечение: всегда
<label for='input-id'>илиaria-label. Связанные поля группируйте<fieldset>и<legend>- радиогруппы, группы чекбоксов. Ошибки связывайте явно:<input aria-describedby='email-error' aria-invalid='true' /> <p id='email-error'>Введите корректный адрес почты</p>. Атрибутaria-invalidзаставляет скринридер объявить «неверный ввод» при фокусе на поле. - Информация только цветом: Ошибки показаны только красным, обязательные поля отмечены только красной звёздочкой, статусные бейджи различаются только цветом. Лечение: дополните цвет текстовой меткой, паттерном, иконкой с альтернативой или другим нецветовым признаком. Красная звёздочка обязана иметь расшифровку:
<span aria-hidden='true'>*</span><span class='sr-only'>(обязательно)</span>либо видимая легенда перед формой. - Динамические изменения без объявления: Подгруженные результаты, тосты, живой поиск - ничто из этого не объявляется скринридером, если не использовать живые области ARIA. Лечение:
aria-live='polite'на контейнере, который получает обновления. Для статусов вроде числа найденного:<div role='status' aria-live='polite'>{resultCount} результатов</div>. Для срочного -role='alert'(подразумеваетaria-live='assertive'). Контейнер обязан существовать в DOM до обновления: вставить живую область и наполнить её в том же такте может не сработать. - Бесконечная прокрутка без выхода с клавиатуры: Частый паттерн в магазинах. Попав табом в бесконечный список, клавиатурный пользователь застревает: список растёт быстрее, чем он по нему идёт. Лечение: либо пагинация, доступная по своей природе, либо кнопка «Показать ещё» в конце текущей партии, до которой можно дойти и осознанно продолжить, либо ссылка «Перейти к концу результатов».
- Автовоспроизведение видео без управления: Нарушает SC 1.4.2 (управление звуком) и SC 2.1.1. Фоновые видео не должны стартовать со звуком. Если стартуют без звука, обязана быть возможность поставить на паузу. У видео с закадровым текстом должны быть субтитры (SC 1.2.2).
Часть VI - Тестирование: workflow, который находит реальные проблемы
Автоматика ловит где-то от 30 до 40% провалов WCAG. Звучит скромно, но гонять её в CI всё равно стоит: механические провалы - отсутствующий alt, нарушения контраста, пропущенные метки - она находит с нулевой предельной стоимостью. Остальные 60% требуют ручной работы, а именно навигации только с клавиатуры и тестов со скринридером. Вот порядок, которым пользуюсь я.
- Автоматика: axe-core в CI:
axe-core- движок под большинством инструментов доступности. Подключается через@axe-core/playwrightили@axe-core/reactдля проверки на уровне компонентов. В CI: прогоняйтеaxeпо каждой странице маршрутизации и валите сборку на любых нарушениях уровняcriticalилиserious. Пример на Playwright:const results = await new AxeBuilder({ page }).analyze(); expect(results.violations.filter(v => ['critical','serious'].includes(v.impact))).toHaveLength(0);. Так автоматически ловятся отсутствующие метки, контраст, неправильное ARIA и нарушения ролей. - Инструменты браузера: панель Accessibility в DevTools: В Chrome DevTools есть панель Accessibility (Elements → Accessibility), которая показывает дерево доступности для любого элемента вместе с вычисленными именем, ролью и состоянием. Через неё удобно понять, почему скринридер объявляет не то имя, которое вы ожидали. Панель доступности в Lighthouse даёт быструю оценку от 0 до 100 и приоритизированный список нарушений, но принимать эту оценку за уровень соответствия нельзя: 97 баллов не означают соответствия WCAG 2.1 AA.
- Проверка клавиатурой: без мыши: Пройдите табом по всем интерактивным элементам страницы. Каждый должен быть достижим, виден в фокусе и управляем через
EnterиSpace. Проверьте модалки: уходит ли фокус внутрь при открытии, закрывает лиEscape, возвращается ли фокус на триггер. Проверьте формы: отправляется лиEnter, достижимы ли ошибки валидации. Проверьте кастомные виджеты: следуют ли они клавиатурным паттернам APG. Это 15–30 минут на страницу и большая часть неавтоматизируемых провалов. - Скринридеры: VoiceOver и NVDA: На macOS - VoiceOver (
Command+F5) с Safari. Включите, закройте глаза или отвернитесь и походите по странице только с клавиатуры, слушая объявления. Ключевые проверки: у каждого интерактивного элемента осмысленное имя и роль, изменения состояния объявляются (меню открыто или закрыто, элемент выбран), сообщения об ошибках зачитываются при появлении. На Windows - бесплатный NVDA с Firefox или Chrome. Пользуйтесь режимом чтения (стрелки) и интерактивным (таб по полям). JAWS распространён в корпоративной среде, но для длительного тестирования нужна лицензия. - Тесты с реальными пользователями: Автоматика и ручная проверка зрячим разработчиком ловят механику. Для когнитивной и перцептивной доступности нужны сессии с людьми, которые действительно живут с ассистивными технологиями: они находят то, что иначе не находится вовсе. Даже одна сессия в квартал с пользователем скринридера вскрывает системные паттерны, которых автоматика не видит в принципе.
Часть VII - Доступные паттерны компонентов
Это компоненты, которые есть почти в каждом фронтенд-продукте и у которых есть отработанные доступные паттерны. Если сделать их правильно, большая часть работы по доступности закрывается на уровне компонентов: страницы, собранные из доступных кирпичей, доступны по построению.
- Модальные диалоги: Обязательные атрибуты:
role='dialog'(илиrole='alertdialog'для подтверждений),aria-modal='true'(говорит скринридеру считать содержимое снаружи инертным),aria-labelledbyна заголовок диалога. Обязательное поведение: фокус уходит в диалог при открытии (на первый фокусируемый элемент или на сам контейнер), фокус заперт внутри,Escapeзакрывает, фокус возвращается на триггер. Нативный<dialog>делает большую часть этого сам - используйте его, где можно.<dialog open>даёт управление фокусом, поведениеEscapeи псевдоэлемент::backdrop. Поддержка браузерами на 2026 год: 96% (caniuse.com). - Валидация формы с живыми сообщениями: Паттерн: валидировать поле на
blur, всё разом - на отправке. При ошибке: поставитьaria-invalid='true'на поле, вставить сообщение в<p id='field-error'>и связать черезaria-describedby='field-error'. Для сводных ошибок, когда упало несколько полей: вставьте сводку в областьaria-live='assertive'в начале формы, переведите туда фокус и перечислите ошибки ссылками, ведущими к соответствующим полям. Так пользователь скринридера слышит ошибки сразу и может дойти до каждого поля. - Живые области ARIA для тостов:
<div role='status' aria-live='polite' aria-atomic='true'>объявляет изменения, не перебивая то, что скринридер читает сейчас.role='alert'(эквивалентaria-live='assertive') перебивает немедленно - берите его только для критических ошибок, не для рутинных уведомлений. Атрибутaria-atomic='true'велит зачитывать всё содержимое области при каждом обновлении, а не только изменившуюся часть. Монтируйте живую область в корневом лейауте, чтобы она была всегда, и меняйте её содержимое при появлении уведомления. - Кастомные выпадающие списки против нативного
<select>: Нативный<select>доступен везде, сам обрабатывает всю клавиатуру, работает на тач-устройствах и на десктопе и не требует ни строчки ARIA. Единственная причина писать своё - свобода стилизации; причина уважительная, но цену надо понимать: вы переизобретаете клавиатурную навигацию (стрелки, поиск по первым буквам,Escape,Enter), управление фокусом и все состояния ARIA. Если всё-таки пишете: берите паттерн combobox из APG, тестируйте в VoiceOver и NVDA и закладывайте постоянную поддержку по мере развития браузеров. - Таблицы данных: Используйте нативные
<table>,<th>,<td>,<caption>. Заголовкам столбцов нуженscope='col', заголовкам строк -scope='row'. Для сложных таблиц с многоуровневыми заголовками связывайте черезidиheaders. Тег<caption>даёт таблице доступное имя. Не собирайте табличные данные на CSS-гридах и div-ах: скринридер не поймёт их как таблицу, пока вы не добавите полную ARIA-семантику, а это заметно больше работы, чем нативные элементы. - Карусели и слайдеры: Самый неправильно реализуемый виджет в электронной коммерции. Требования: кнопка паузы для автопрокрутки (SC 2.2.2), кнопки вперёд и назад с описательным
aria-label(«Следующий слайд: товар 3 из 8»),aria-live='polite'на контейнере слайдов для каруселей без автопрокрутки, навигация стрелками между слайдами. Честный ответ: товарные карусели проваливают доступность настолько стабильно, что статичная сетка товаров оказывается и доступнее, и часто конверсионнее.
Часть VIII - Доступность в Next.js App Router
У App Router есть особенности доступности, вытекающие из его модели рендеринга. Главная - переходы между маршрутами: клиентская навигация не вызывает того сброса фокуса, который происходит при обычной загрузке страницы. В классических многостраничных приложениях переход автоматически уводит фокус в начало страницы. В приложении на Next.js клик по <Link> обновляет содержимое без полной перезагрузки, а значит фокус остаётся там, где был, и пользователь скринридера может не заметить, что страница сменилась.
- Управление фокусом при переходах: App Router управляет фокусом при навигации автоматически начиная с Next.js 14: он переводит фокус на элемент
<body>, что заставляет скринридер объявить новый заголовок страницы. Это лучше прежнего поведения, но хуже, чем перевод фокуса на первый заголовок новой страницы. Если у приложения постоянный лейаут с липкой шапкой, проверьте, что ссылка пропуска навигации работает и после клиентского перехода: элемент<main>сtabindex='-1'должен получать фокус при её активации. - Серверные компоненты и ARIA: Атрибуты ARIA в серверных компонентах - это обычный серверный HTML, скринридер разбирает их из исходного ответа так же, как любой другой атрибут. Никаких особенностей тут нет. Тонкость возникает с динамическим состоянием:
aria-expanded='false'отрисовать на сервере можно, а вот переключить его вtrueпо действию пользователя способен только клиентский компонент. Чистый паттерн - резать по интерактивной границе: клиентский<DisclosureButton>держит состояниеaria-expanded, а раскрываемое содержимое приходит серверным компонентом черезchildren. @radix-ui/react-*для доступных примитивов: Radix UI даёт доступные нестилизованные примитивы под типовые виджеты:Dialog,DropdownMenu,Select,Tabs,Accordion,AlertDialog,Tooltip. Каждый реализует полный паттерн ARIA APG для своего типа, включая клавиатуру, управление фокусом и состояния. Взять Radix (или аналог: Headless UI, Ark UI, React Aria от Adobe) фундаментом библиотеки компонентов - значит получить корректное поведение ARIA сразу и тратить силы на стилизацию, а не на реализацию доступности. Для команд, строящих дизайн-систему, это самый выгодный путь.- Доступность и Core Web Vitals: Связь прямая. Сдвиг лейаута (CLS), когда содержимое неожиданно уезжает, дезориентирует людей с когнитивными особенностями и катастрофичен для пользователей скринридера, у которых при смене раскладки съезжает виртуальный курсор. Длинные задачи, то есть корень провалов INP, задерживают перемещение фокуса и объявления живых областей, создавая разрыв между действием пользователя и реакцией скринридера. Оптимизируя Core Web Vitals ради скорости, вы улучшаете и доступность по тем же причинам: медленная прыгающая раскладка бьёт непропорционально сильно по тем, кто зависит от предсказуемого тайминга взаимодействия. Про производительность - в гайде по веб-производительности.
Заключение
Долг по доступности в большинстве продакшен-приложений растёт из одного источника: интерактивные компоненты собраны из общих элементов, метки существуют только визуально, изменения состояния невидимы дереву доступности, а управление фокусом никто не продумывал. По отдельности ничего из этого чинить не сложно; сложно чинить это в масштабе задним числом.
Практический путь вперёд: добавьте axe-core в CI на этой неделе - это час настройки, и провалы начнут ловиться сразу. Внесите клавиатурную проверку в определение готовности для любого нового интерактивного компонента. Начните использовать нативные элементы там, где сейчас стоят <div>: <button>, <a>, <input>, <select>, <details> - и половина WCAG 2.2 AA закроется бесплатно. Для сложных виджетов возьмите Radix UI или React Aria и не пишите ARIA вручную вовсе.
Аудит доступности или реализация доступного фронтенда - услуга аудита доступности | техническое SEO и структурированные данные | обсудить проект.
Частые вопросы
Чем WCAG 2.2 отличается от WCAG 2.1?
WCAG 2.2 (опубликован в октябре 2023) добавляет к 2.1 девять новых критериев успеха, в основном про видимость фокуса, когнитивную доступность и аутентификацию. Ключевые добавления уровня AA: SC 2.4.11 (Focus Not Obscured - сфокусированный элемент не может быть целиком скрыт липкой шапкой), SC 2.5.7 (Dragging Movements - у перетаскивания должна быть альтернатива одним указателем), SC 3.2.6 (Consistent Help - механизмы помощи находятся на одном месте на всех страницах) и SC 3.3.8 (Accessible Authentication - для входа нельзя требовать когнитивных тестов вроде головоломок или запоминания). WCAG 2.2 обратно совместим: соответствие 2.2 AA означает соответствие 2.1 AA.
Перекрывает ли aria-label видимый текст?
Да. aria-label заменяет имя, вычисленное из содержимого элемента. Для <button aria-label='Закрыть'>X</button> скринридер объявит «Закрыть, кнопка», а не «X, кнопка». Для иконочных кнопок, где видимое содержимое ничего не описывает, это правильно. Но aria-label на элементе, чей видимый текст говорит другое, создаёт расхождение, которое сбивает с толку тех, кто одновременно видит текст и слышит скринридер. Используйте aria-label только когда видимого текста, годного в качестве имени, просто нет.
Достаточно ли, чтобы сайт работал с одним скринридером?
Нет. VoiceOver с Safari, NVDA с Firefox, JAWS с Chrome и TalkBack с Chrome в пограничных случаях трактуют ARIA по-разному. Опрос пользователей скринридеров WebAIM (2024) показывает, что JAWS и NVDA вместе занимают 70% десктопного использования. Минимум - тестировать в NVDA с Firefox и в VoiceOver с Safari. Компоненты Radix UI и React Aria протестированы во всех основных сочетаниях.
Что делать со сторонними компонентами, которые не проходят проверку доступности?
Сначала: заведите issue в библиотеке и проверьте, нет ли версии, где это уже починили. Для критических нарушений оберните компонент и добавьте атрибуты ARIA на уровне обёртки, где это возможно: aria-label на обёрточном <div> не починит отсутствующие метки кнопок внутри, но связи через aria-describedby добавить в месте использования можно. В крайнем случае напишите компонент сами или найдите альтернативную библиотеку. Никогда не оставляйте /* eslint-disable jsx-a11y */ как постоянное решение: это прячет проблему, а не решает её.
На какой автоматический показатель доступности ориентироваться?
Оценка доступности в Lighthouse выше 95 - разумный нижний порог для CI: всё, что ниже, указывает на провалы, которые автоматика способна найти. Но принимать оценку за соответствие нельзя: сайт может набрать 100 в Lighthouse и при этом провалить несколько критериев WCAG 2.2 AA, которые проверяются только руками - управление фокусом, клавиатурные взаимодействия, поведение живых областей. Оценка - опережающий индикатор, а не сертификат.
Источники
- W3C. «Web Content Accessibility Guidelines (WCAG) 2.2»
- W3C. «ARIA Authoring Practices Guide (APG)»
- W3C. «Using ARIA»
- WebAIM. «Screen Reader User Survey #10 (2024)»
- ВОЗ. «Disability and Health Fact Sheet»
- UsableNet. «ADA Web Accessibility Lawsuit Report 2023»
- MDN Web Docs. «Accessibility»
- Deque. «axe-core accessibility engine»
- Radix UI. «Accessible component primitives»
- Adobe. «React Aria»
- Scott O'Hara. «Accessible Components»
- Léonie Watson. «Screen Reader Behavior Research»
