Skip to content

Веб-доступность в 2026: скринридеры, WCAG 2.2 и практики, которые реально работают

Доступность - это не чеклист, который запускают в конце спринта. Это набор структурных решений - семантический HTML, управление фокусом, ARIA-контракты - которые либо закладываются в процессе разработки, либо обходятся втрое дороже при рефакторинге.

Веб-доступность: скринридер, клавиатурная навигация, диаграмма WCAG 2.2 и ARIA roles
Автор:Опубликовано:Обновлено:Время чтения:22 мин чтения

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, которые проверяются только руками - управление фокусом, клавиатурные взаимодействия, поведение живых областей. Оценка - опережающий индикатор, а не сертификат.

Источники

Похожие статьи

Shopify Hydrogen vs Next.js Commerce: какую архитектуру выбрать для магазина в 2026

Детальное сравнение двух основных headless-архитектур для Shopify. Модель исполнения, слой данных, стратегия кэширования, размер бандла, бенчмарки производительности (LCP/INP/CLS), TCO, риск вендор-локина и практический фреймворк решений между нативной глубиной Hydrogen и компонуемой гибкостью Next.js Commerce.

eCommerceNext.jsShopify
Читать статью

Архитектура веб-производительности: системный анализ 12 инженерных принципов (издание 2026)

Скачок Lighthouse на 25 пунктов - с 72 до 99 на десктопе - редко складывается из сотни микроправок; он берётся из переноса двух-трёх ассетов с основного потока. Глубокий технический разбор веб-производительности 2026 года: физика сетей, конвейеры изображений, модели выполнения JavaScript, Critical Rendering Path, edge-вычисления, Core Web Vitals и продакшн RUM - с реальным продакшн-кейсом и конкретными примерами реализации.

EngineeringArchitectureCore Web Vitals
Читать статью

React 19: useOptimistic, use(), Server Actions - механизм и архитектура (2026)

Детальный разбор пяти новых примитивов React 19: useOptimistic (конкурентная composition оптимистичного состояния с автоматическим rollback), use() (условное чтение Promise и Context), Server Actions (RPC-over-HTTP контракт и Progressive Enhancement), useActionState (стейт-threading паттерн), useFormStatus. Action-based mutation архитектура, которая заменяет Redux/Zustand для серверных мутаций.

React 19ArchitectureNext.js
Читать статью