Скринридеры: как они читают страницу, NVDA/VoiceOver на практике
У большинства разработчиков картина примерно такая: скринридер — программа, которая читает вслух то, что нарисовано на экране, сверху вниз. Из этой картины следуют неверные выводы («значит, надо просто подписать картинки»), бесполезные баг-репорты («он читает не то, что видно») и неправильные приоритеты.
Реальность интереснее и практичнее. Скринридер не смотрит на экран, у него не один курсор, а три, он держит собственную копию документа и переключает режимы работы в зависимости от того, где вы находитесь. Разобравшись в этой механике, вы перестаёте угадывать и начинаете предсказывать: услышит человек вашу кнопку или нет, объявится ли ошибка формы, почему одна и та же разметка звучит по-разному в NVDA и VoiceOver.
Базовые понятия — дерево доступности, роль/имя/состояние — заложены в обзоре трека и в гайде по accessibility; повторять их целиком не будем. Здесь — механика самого скринридера и практика работы с ним.
Скринридер — это клиент API доступности, а не «читалка экрана»
Название историческое. В эпоху DOS и ранней Windows программы этого класса действительно скребли видеобуфер, перехватывая вызовы отрисовки текста. Приём назывался screen scraping, работал плохо и умер вместе с графическими интерфейсами, где «текст на экране» перестал быть текстом.
Современный скринридер — клиент платформенного API доступности. Он не знает, где что нарисовано; он запрашивает у приложения дерево объектов и спрашивает каждый узел: какая у тебя роль, имя, состояние, какие связи с соседями. Браузер обязан на это ответить.
| Платформа | API доступности | Кто им пользуется |
|---|---|---|
| Windows | UI Automation, IAccessible2, устаревший MSAA | NVDA, JAWS, «Экранный диктор», Dragon |
| macOS и iOS | NSAccessibility и UIAccessibility | VoiceOver, Voice Control, Switch Control |
| Linux | AT-SPI2 поверх D-Bus | Orca |
| Android | AccessibilityNodeInfo | TalkBack, Voice Access, Switch Access |
Отсюда три следствия, из которых растёт всё остальное.
Чего нет в дереве доступности — того не существует. Не «плохо читается», а буквально не существует. Красивый div с обработчиком onClick виден вам и невидим скринридеру: в дереве это безымянный контейнер с ролью generic, а такие узлы при быстрой навигации просто пропускаются. Обратное тоже верно: элемент, спрятанный через clip-path: inset(50%), в дереве остаётся и будет прочитан.
Внешний вид не имеет значения вообще. Пиксельный порядок, order во флексбоксе, grid-area, float, абсолютное позиционирование — ничто из этого не меняет порядок чтения. Скринридер идёт по дереву, а дерево строится из DOM. Это главная причина расхождений «глазами логично, на слух каша»; подробности — в статье про клавиатуру и фокус.
Баг воспроизводится на паре «браузер + скринридер», а не на скринридере. Дерево строит и транслирует в платформенный API браузер, и делают это Chrome, Firefox и Safari по-разному. «NVDA не читает кнопку» — это не шаги воспроизведения. «NVDA 2024.4 + Chrome 131 не читает кнопку, в Firefox читает» — шаги воспроизведения.
Что происходит между нажатием клавиши и звуком
Разберём банальный жест: человек в режиме обзора нажимает H, чтобы прыгнуть к следующему заголовку.
Обратите внимание на последнюю ремарку. Прыжок по заголовкам не меняет document.activeElement, не порождает focus-событий и не включает псевдокласс :focus. Ваш код о нём не узнает ничего. Поэтому «покажем подсказку по фокусу» — ненадёжная стратегия: человек может провести на элементе десять секунд, не сфокусировав его.
Второй важный момент — шаг 4: скринридер не ходит в дерево на каждое нажатие, он один раз строит виртуальный буфер.
Виртуальный буфер: почему скринридер иногда читает прошлое
Виртуальный буфер (в NVDA — browse mode document, в JAWS — Virtual PC Cursor, в «Экранном дикторе» — режим сканирования) — плоское текстовое представление всей страницы в памяти скринридера. По нему ходят стрелками как по текстовому файлу, ищут текст, копируют фрагменты, прыгают по типам элементов. Без него навигации по документу не было бы: дерево доступности — дерево, а читать человек привык линейно.
Буфер обновляется по событиям от браузера, и здесь начинаются практические проблемы.
Состояние Stale — источник целого класса багов в SPA. Обновление без событий: если DOM меняется способом, который браузер не транслирует как изменение поддерева, буфер остаётся старым; спасает NVDA + F5, но догадаться про эту клавишу человек не обязан. Смена «страницы» без смены страницы: роутер подменил содержимое main, URL изменился, а для скринридера ничего не произошло — заголовок документа тот же, фокус там же, человек продолжает читать со старого места. Отложенный рендер: контент, появившийся через 300 мс после клика, в буфер попадёт, но курсор к нему не переедет; если ничего не объявлено — тишина, и человек решит, что кнопка не сработала.
У VoiceOver на macOS виртуального буфера в этом смысле нет. Он работает по живому дереву, поэтому реагирует на изменения быстрее и «застревает в прошлом» реже — зато у него свои сложности с группировкой и необходимостью «войти внутрь» составных конструкций. Это одна из главных причин, почему разметка, вылизанная под NVDA, звучит иначе в VoiceOver.
Три курсора: главный источник недопонимания
В десктопном скринридере одновременно живут несколько независимых позиций. Разработчики обычно знают про одну.
| Курсор | Чем двигают | Что видит браузер | Где обычно ломается |
|---|---|---|---|
| Системный фокус | Tab, Shift+Tab, программный .focus() |
всё: activeElement, :focus, события |
ловушки фокуса, потеря фокуса при удалении узла |
| Виртуальный курсор | стрелки, быстрые клавиши H, K, B, T, D |
ничего | уход за пределы модалки, чтение скрытого контента |
| Объектная навигация | NVDA + цифровой блок, VO + стрелки |
ничего | показывает мусор и лишние обёртки в дереве |
Практический вывод стоит запомнить дословно: закрыть фон модального окна для клавиатуры недостаточно. tabindex="-1" на фоновых элементах и ловушка Tab останавливают только курсор №1. Курсор №2 уйдёт читать страницу под модалкой, потому что для него никакой модалки нет — есть плоский текст документа. Правильный инструмент — атрибут inert на фоновом контейнере: он убирает поддерево и из фокуса, и из дерева доступности.
<!-- Модалка открыта: фон недоступен ни фокусу, ни виртуальному курсору -->
<div id="app" inert>…весь остальной интерфейс…</div>
<div role="dialog" aria-modal="true" aria-labelledby="dlg-title">
<h2 id="dlg-title">Подтвердите заказ</h2>
</div>
Атрибут поддерживается всеми актуальными браузерами, описание — на MDN. Для старых окружений остаётся полифилл или ручная простановка aria-hidden="true" на всём, кроме диалога.
Режимы работы: обзор, формы и быстрая навигация
У NVDA и JAWS два режима, и переключаются они автоматически.
| Что происходит | Режим | Почему |
|---|---|---|
Фокус пришёл в input, textarea, select |
формы | иначе нельзя было бы напечатать букву h |
Фокус на роли textbox, combobox, listbox, grid, menu, tree, slider |
формы | роль обещает собственную клавиатурную модель |
Фокус на button, ссылке, чекбоксе |
обзор | им хватает Enter и Пробела |
Внутри role="application" |
формы всегда | вы забрали у скринридера всю навигацию |
Нажали NVDA + Пробел |
переключение вручную | аварийный выход |
Отсюда два правила, экономящие недели отладки.
Роль — это контракт на клавиатуру. Поставили role="listbox" — обязаны реализовать ↑/↓, Home, End, поиск по первой букве и aria-activedescendant. Не реализовали — человек оказался в режиме, где быстрые клавиши скринридера отключены, а обещанные ролью клавиши не работают. Из такого компонента буквально нет выхода, кроме NVDA + Пробел, о котором знают не все. Это хуже полного отсутствия ARIA; подробный разбор — в статье про ARIA.
role="application" — почти всегда ошибка. Он говорит: «это не документ, это приложение, отдай мне все клавиши». Оправдан для схемы метро с собственной навигацией или редактора нот. Для дашборда, календаря или конструктора форм — нет: вы отбираете навигацию по заголовкам, ссылкам и ориентирам и ничего не даёте взамен.
В VoiceOver режимов в этом смысле нет, но есть Quick Nav — включается одновременным нажатием ← и →. Пока он включён, работают одиночные буквы и стрелки без модификатора; в поле ввода его надо выключать. Тот же компромисс между «клавиши скринридеру» и «клавиши странице», просто с ручным переключателем.
Анатомия произносимой фразы
Когда курсор встаёт на элемент, скринридер собирает фразу из нескольких сегментов. Понимание их порядка и источников — самый быстрый способ научиться читать баг-репорты вида «он говорит какую-то ерунду».
Ключевой сегмент — имя (accessible name). Оно вычисляется по строгому алгоритму из спецификации Accessible Name and Description Computation с таким приоритетом: aria-labelledby (побеждает всё, склеивая текст перечисленных узлов) → aria-label → нативный источник (<label for>, alt, <caption>, <legend>, содержимое <button> и <a>) → title как последний резерв.
Отсюда самая частая и самая обидная ошибка: aria-label перекрывает видимый текст.
<!-- Плохо: глазами «Отправить», на слух «Submit form» -->
<button aria-label="Submit form">Отправить</button>
<!-- Иконка без текста: имя обязательно, иконка спрятана от дерева -->
<button aria-label="Удалить из корзины">
<svg aria-hidden="true" focusable="false" width="16" height="16">…</svg>
</button>
<!-- Нужен контекст: расширяем видимый текст, а не заменяем его -->
<a href="/catalog/chairs/123">Подробнее<span class="visually-hidden"> о стуле Ergo</span></a>
Первый пример — не просто путаница, а нарушение критерия WCAG 2.2 — 2.5.3 Label in Name: видимая подпись должна входить в доступное имя. Ломается это в первую очередь у людей, управляющих компьютером голосом: они говорят «нажми Отправить», а система такой кнопки не находит. Третий вариант предпочтительнее aria-label ещё и потому, что текст переводится обычными механизмами локализации, а не отдельной строкой в атрибуте.
Про описание (aria-describedby) важно знать одно: оно читается последним, с паузой, легко «переезжается» следующей клавишей, а во многих настройках многословности выключено вовсе. Значит, критичная информация в описании жить не может. «Пароль от 8 символов» — можно, это подсказка. «Нажатие удалит аккаунт без восстановления» — нельзя, это должно быть в видимом тексте.
Всё произносимое параллельно уходит на брайлевскую строку в сокращённом виде: роли превращаются в аббревиатуры (btn, lnk, edt, cbo), состояния — в значки. Брайлем пользуется заметная доля респондентов опросов WebAIM, и для них «милые» хаки вроде эмодзи в aria-label или пунктуации для управления интонацией превращаются в мусор под пальцами.
NVDA на практике
NVDA — бесплатный, открытый, для Windows, скачивается с nvaccess.org. Для разработчика это основной инструмент: он ближе всех к спецификациям, у него огромная реальная доля пользователей и есть Speech Viewer.
Первое, что нужно включить. Меню NVDA (NVDA + N) → Инструменты → Просмотр речи. Открывается окно, куда текстом дублируется всё произнесённое: вы читаете глазами то, что слышит человек, и копируете строку в тикет как «услышано». Там же — Просмотр брайля. Без этих двух окон отладка превращается в мучение: попробуйте на слух зафиксировать точную формулировку на скорости 350 слов в минуту.
Второе — подсветка фокуса. Настройки → Зрение → Включить подсветку. NVDA начнёт рисовать рамки вокруг системного фокуса, объектного навигатора и виртуального курсора разными цветами. Для понимания «трёх курсоров» это лучше любой статьи.
| Действие | Клавиши |
|---|---|
| Запустить и выйти | Ctrl + Alt + N и NVDA + Q |
| Клавиша NVDA | Insert или CapsLock, выбирается при установке |
| Читать всё с текущего места | NVDA + ↓ |
| Остановить речь | Ctrl |
| Прочитать элемент под фокусом | NVDA + Tab |
| Заголовок окна и адрес | NVDA + T |
| Список элементов страницы | NVDA + F7 |
| Переключить режим обзора и форм | NVDA + Пробел |
| Обновить виртуальный буфер | NVDA + F5 |
| Заголовки: следующий, предыдущий, по уровням | H, Shift + H, 1…6 |
| Ссылка, кнопка, поле, чекбокс, комбобокс | K, B, F, X, C |
| Ориентир, список, таблица, картинка | D, L, T, G |
| Навигация по ячейкам таблицы | Ctrl + Alt + ←↑→↓ |
| Прочитать форматирование под курсором | NVDA + F |
| Режим речи: говорить, бипы, по требованию, тишина | NVDA + S |
Отдельно про NVDA + F7 — список элементов. Диалог с вкладками: ссылки, заголовки, поля формы, кнопки, ориентиры. Это самый быстрый аудит структуры из существующих. Откройте его на своей странице прямо сейчас: вкладка «Заголовки» покажет, есть ли осмысленный оглавительный план, вкладка «Ссылки» — сколько раз повторяется «Подробнее» без контекста.
VoiceOver на практике
VoiceOver встроен в macOS и iOS, включается Cmd + F5 и не требует установки. Модификатор VO — это Control + Option; в настройках его можно повесить на CapsLock, что резко упрощает жизнь.
Включите Caption Panel. VO + F8 открывает VoiceOver Utility → «Визуализация» → «Показывать панель субтитров». Это аналог Speech Viewer: всё произнесённое дублируется текстом внизу экрана; там же включается панель брайля. И потренируйтесь в VO + K — режиме, где нажатые клавиши только объявляются, но ничего не делают: пять минут в нём избавляют от паники «я нажал что-то и всё сломалось».
| Действие | Клавиши |
|---|---|
| Включить и выключить VoiceOver | Cmd + F5 |
| Читать всё | VO + A |
| Следующий и предыдущий элемент | VO + → и VO + ← |
| Активировать | VO + Пробел |
| Ротор | VO + U, категории — ←/→, элементы — ↑/↓ |
| Быстрая навигация | одновременно ← и → |
| Следующий заголовок, ссылка, таблица, элемент управления | VO + Cmd + H, L, T, J |
| Войти внутрь группы и выйти | VO + Shift + ↓ и VO + Shift + ↑ |
| Справка по клавиатуре | VO + K |
Ротор (VO + U) — прямой аналог списка элементов NVDA и главный инструмент навигации: категории перелистываются влево-вправо (заголовки, ссылки, элементы управления, ориентиры, таблицы), внутри категории — стрелки вверх-вниз и Enter.
Две вещи удивляют при переходе с NVDA. Группировка и «вход внутрь»: VoiceOver часто объявляет составные конструкции как группу и требует VO + Shift + ↓, чтобы попасть внутрь, — если вы обернули каждую карточку товара в лишний role="group" или наставили <fieldset> без нужды, человек получит лишний уровень вложенности на каждом шаге. Пара с браузером значит больше, чем на Windows: VoiceOver вылизан под Safari, в Chrome поведение заметно отличается, особенно у живых областей. Проверять надо в Safari — так работают реальные пользователи. Документация: руководство по VoiceOver, шпаргалки по всем скринридерам — у Deque University.
Мобильные скринридеры
На телефоне скринридер меняет всю модель ввода: обычные касания перехватываются, интерфейс исследуется пальцем или свайпами.
| Жест | VoiceOver для iOS | TalkBack для Android |
|---|---|---|
| Следующий и предыдущий элемент | свайп вправо и влево | свайп вправо и влево |
| Активировать | двойное касание в любом месте экрана | двойное касание |
| Читать всё с начала | свайп двумя пальцами вниз | меню чтения, свайп вниз затем вправо |
| Смена гранулярности | ротор — вращение двумя пальцами | элементы управления чтением, свайп вверх и вниз |
| Прокрутка | свайп тремя пальцами | свайп двумя пальцами |
| Назад | «зигзаг» двумя пальцами | свайп вниз затем влево |
Что ломается на мобильных чаще, чем на десктопе: кастомные жесты приложения до карусели не дойдут — их съедает скринридер, поэтому всегда нужна альтернатива кнопками; исследование пальцем возвращает значимость визуальному порядку, ведь объявляется то, что физически под пальцем; размер целей — WCAG 2.2 добавил критерий 2.5.8 Target Size с минимумом 24 на 24 CSS-пикселя; фокус после навигации — смена экрана без переноса фокуса оставляет человека слушать предыдущий экран.
Как читаются конкретные конструкции
Заголовки и ориентиры. Заголовки — не типографика, а оглавление: около двух третей опрошенных WebAIM на незнакомой длинной странице первым делом идут по ним. Требования простые: один h1, уровни без пропусков, текст отражает содержание секции. Ориентиры — второй слой навигации: header → banner, nav → navigation, main → main, aside → complementary, footer → contentinfo. Если ориентиров несколько одного типа, им нужны имена — но слово «навигация» в имя писать не надо, роль и так будет прочитана:
<nav aria-label="Основное меню">…</nav>
<nav aria-label="Хлебные крошки">…</nav>
<!-- «Основное меню, навигация» и «Хлебные крошки, навигация» -->
Ссылки и кнопки. Ссылка ведёт куда-то, кнопка что-то делает; скринридер объявляет это разными словами, и человек ждёт разного поведения. Текст ссылки должен быть понятен в отрыве от окружения — потому что в списке ссылок окружения нет. Критерий 2.4.4 Link Purpose формально допускает контекст, но практика жёстче: двадцать одинаковых «Подробнее» бесполезны. Разбор нативной семантики — в статье про фундамент и в материале про семантику HTML.
Списки. <ul> и <ol> дают бесплатную и очень ценную информацию: «список, 7 элементов», «пункт 3 из 7» — человек сразу понимает объём и своё место в нём. Замена списка на divы отбирает и то, и другое. Осторожно с list-style: none: в Safari это CSS-свойство снимает роль списка, лечится role="list" на контейнере — редкий случай, когда ARIA нужна для восстановления сломанной CSS семантики.
Таблицы. В таблице включается специальный режим: при переходе по ячейкам (Ctrl + Alt + стрелки) объявляется заголовок столбца и строки для каждой ячейки. Работает только если заголовки размечены.
<table>
<caption>Тарифы доставки по городам</caption>
<thead>
<tr><th scope="col">Город</th><th scope="col">Срок</th><th scope="col">Цена</th></tr>
</thead>
<tbody>
<tr><th scope="row">Казань</th><td>2 дня</td><td>350 руб.</td></tr>
</tbody>
</table>
<!-- На ячейке «350 руб.» человек услышит «Казань, Цена, 350 рублей».
Без scope и th — только «350 рублей», то есть ничего полезного. -->
<caption> — заголовок таблицы, он же её доступное имя, он же то, что покажет список таблиц. Таблицу для раскладки (так делать не надо, но легаси существует) помечают role="presentation", чтобы табличный режим не включался.
Изображения. Три случая, три решения: содержательная картинка получает alt с описанием функции или смысла; декоративная — пустой alt="" (именно пустой, а не отсутствующий); сложная диаграмма — краткий alt плюс развёрнутое текстовое описание рядом. Отсутствие атрибута заставляет скринридер читать имя файла, а IMG_2024_final_v3.png по буквам — издевательство. Для инлайновых SVG-иконок внутри кнопок обязательны aria-hidden="true" и focusable="false".
Язык. Атрибут lang переключает голос синтезатора. Без lang="ru" русский текст будет прочитан английским голосом — получается фонетическая каша, которую невозможно разобрать. Для вкраплений — lang на элементе: <span lang="en">continuous delivery</span>. Это критерии 3.1.1 и 3.1.2. Чего делать не надо — подгонять произношение хаками: aria-label="Эс Ку Эль" вместо «SQL» ломает брайль, копирование и голосовое управление. Синтезаторы умеют читать аббревиатуры, а люди умеют их слушать.
Объявления об изменениях: живые области
Самая частая жалоба на современные интерфейсы: «я нажал кнопку и не понял, случилось ли что-нибудь». Визуально появился тост, спиннер или счётчик корзины — на слух не появилось ничего. Это критерий WCAG 2.2 — 4.1.3 Status Messages, уровень AA.
Инструмент — живые области (live regions). Скринридер следит за помеченным контейнером и объявляет изменения его содержимого, не перемещая туда ни один из курсоров.
<!-- Контейнер существует в DOM ЗАРАНЕЕ и пустой -->
<div id="cart-status" role="status" aria-live="polite" class="visually-hidden"></div>
function announce(text) {
const region = document.getElementById('cart-status');
// Очистка перед вставкой: повторное одинаковое сообщение
// многие скринридеры молча проигнорируют
region.textContent = '';
// Задержка нужна, чтобы изменение стало отдельной мутацией дерева
setTimeout(() => { region.textContent = text; }, 50);
}
announce('Стул Ergo добавлен в корзину. В корзине 3 товара.');
Правила, которые нарушают чаще всего:
- Область должна быть в DOM до изменения. Если вставить контейнер с
aria-liveвместе с текстом внутри, значительная часть скринридеров промолчит: они успели подписаться только на то, что было. Монтируйте пустой контейнер при загрузке приложения. politeпо умолчанию,assertive— почти никогда.assertive(и рольalert) перебивает текущую речь на полуслове. Это оправдано для «сессия истекает через минуту», а не для «письмо отправлено».- Роли вместо атрибутов.
role="status"равенaria-live="polite"плюсaria-atomic="true";role="alert"—assertiveплюсatomic. Роли короче и лучше поддержаны. aria-atomicопределяет, читать ли регион целиком. «В корзине 3 товара» сaria-atomic="true"прочтётся полностью; без него скринридер может объявить только изменившийся кусок — «3».- Не вешайте живую область на то, что меняется постоянно. Прогресс-бар, таймер, счётчик символов превращают речь в непрерывный поток. Для прогресса есть
role="progressbar"сaria-valuenow, для объявлений — троттлинг: 25%, 50%, 75%, 100%. - Не дублируйте. Если одновременно переводить фокус на сообщение об ошибке и объявлять его через живую область, человек услышит текст дважды.
Спиннеры и загрузка — отдельная боль. Правильный минимум: aria-busy="true" на кнопке плюс заранее смонтированный role="status", текст которого меняется на «Сохраняем…», затем на «Сохранено» или на текст ошибки. Подробный разбор — ARIA live regions на MDN; формы, валидация и объявление ошибок — тема статьи про формы.
Что ломается: диагностика
Когда скринридер «говорит ерунду», причина почти всегда в одном из шести мест. Вот дерево, которым удобно пользоваться прямо во время отладки.
как ожидалось"] --> B{"Элемент вообще
объявляется?"} B -->|Нет| C{"Что видно в DevTools
на вкладке Accessibility?"} C -->|Узла нет| D["Узел выкинут из дерева:
aria-hidden, display none,
hidden, inert или предок с aria-hidden"] C -->|Узел есть, имя пустое| E["Нет доступного имени:
иконка без aria-label,
поле без label, пустая ссылка"] C -->|Роль generic| F["div вместо button или a:
вернуть нативный элемент"] B -->|Да, но не то| G{"Что именно не так?"} G -->|Читает чужой текст| H["aria-labelledby указывает
не туда или id не уникален"] G -->|Английский голос на русском| I["Нет lang на html
или на вкраплении"] G -->|Состояние не меняется| J["aria-expanded, aria-checked,
aria-selected не обновляются
в JS вместе с классами"] G -->|Странный порядок| K["DOM не совпадает с визуальным
порядком: flex order, grid, absolute"] B -->|Молчит при изменении| L{"Есть живая область?"} L -->|Нет| M["Добавить role status
или перевести фокус"] L -->|Есть| N["Смонтирована заранее?
Не скрыта display none?
Текст реально меняется?"] D --> Z["Проверить в Speech Viewer
и повторить сценарий"] E --> Z F --> Z H --> Z I --> Z J --> Z K --> Z M --> Z N --> Z
Каталог типовых поломок:
- Иконка-кнопка без имени. Услышано: «кнопка». Ожидалось: «Удалить, кнопка».
aria-hiddenна фокусируемом элементе. «Призрачный фокус»:Tabостанавливается на элементе, который скринридер объявить не может. Абсолютное правило:aria-hidden="true"и фокусируемость несовместимы.- Состояние в классе, а не в ARIA. Аккордеон меняет
class="open", аaria-expandedостаётсяfalse. Глазами открыто, на слух свёрнуто. Любое визуальное состояние обязано иметь отражение в дереве доступности. placeholderвместоlabel. При заполнении подсказка исчезает; вернувшись проверить ввод, человек слышит «поле ввода, Иван». Плюс провал критерия 3.3.2.display: noneна тексте, который нужно озвучить. Прячьте классом, а не свойством:
/* Видно скринридеру, не видно глазами */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
- Модалка без
inertи без возврата фокуса — разобрана выше, подробнее в статье про компоненты. - Бесконечная лента без объявления. Подгрузилось 20 карточек — тишина. Нужен
role="status"с «Загружено ещё 20 товаров, всего 60». - Автофокус в неожиданном месте.
autofocusна поле поиска выкидывает человека в середину страницы мимо заголовка и меню. - Кастомный
selectбез клавиатурной модели.role="combobox"включает режим форм, а стрелки не работают; смотри паттерн Combobox в ARIA APG. - Таблица без
th. Данные превращаются в поток чисел без привязки к смыслу.
Разница между скринридерами и как с ней жить
Скринридеры появлялись в разное время под разные API: JAWS — с 1995 года и до сих пор стандарт де-факто в корпоративном секторе, VoiceOver — с 2005-го и первый встроенный прямо в ОС, NVDA — с 2006-го, бесплатный и открытый, TalkBack — с эпохи первых Android. Расхождения между ними не «баги», а история и разные представления о том, как должен звучать веб.
Практическая матрица проверок, отсортированная по пользе на единицу усилий:
| Приоритет | Пара | Зачем |
|---|---|---|
| 1 | NVDA + Chrome или Firefox | бесплатно, огромная доля, ближе всех к спецификации |
| 2 | VoiceOver + Safari на macOS | вторая по массовости платформа, своя модель поведения |
| 3 | VoiceOver + Safari на iOS | реальный мобильный опыт |
| 4 | JAWS + Chrome | корпоративный сегмент и госсектор |
| 5 | TalkBack + Chrome на Android | замыкающая проверка |
Правило, экономящее нервы: не подгоняйте разметку под конкретный скринридер. Если вы пишете по спецификации и по паттернам ARIA APG, расхождения будут в формулировках, а не в работоспособности. Как только вы добавляете aria-label «чтобы VoiceOver прочитал правильно», вы почти наверняка ломаете что-то в NVDA. Поддержку конкретных атрибутов удобно сверять на a11ysupport.io.
Ручной прогон за пятнадцать минут
Это не полный аудит (он — в статье про тестирование и процесс), а минимальная процедура для каждой заметной фичи. Подготовка: запустить NVDA, открыть Speech Viewer, развернуть его рядом с браузером.
- Первые тридцать секунд страницы (2 мин).
Ctrl + Home, затемNVDA + ↓. Слушайте, не глядя на экран. Понятно ли за 20 секунд, что это за страница? Не читается ли перед контентом сто ссылок меню (нужен skip-link)? Нет ли мусора — имён файлов, «изображение изображение», пустых кнопок? - Структура (2 мин).
NVDA + F7→ «Заголовки»: прочитайте список как оглавление, он должен быть осмысленным и без дыр в уровнях. Вкладка «Ссылки»: сколько неразличимых? Вкладка «Ориентиры»: есть лиmain, не потерялся ли контент вне ориентиров. - Основной сценарий с закрытыми глазами (5 мин). Пройдите ключевой путь продукта, не глядя на экран. Именно здесь находится большая часть реальных проблем. Фиксируйте каждый момент, когда вы не поняли, что произошло.
- Изменения состояния (3 мин). Спровоцируйте ошибку валидации, успешную отправку, открытие модалки, подгрузку списка, удаление элемента. На каждое — вопрос: «сказал ли скринридер хоть что-нибудь?». Тишина равна багу.
- Границы (3 мин). Откройте модалку и попробуйте уйти из неё стрелками, а не
Tab. Убедитесь, что фон недоступен. Закройте — фокус обязан вернуться на кнопку, которая её открыла.
Баг-репорт пишется в формате, который снимает все вопросы:
Окружение: NVDA 2024.4 + Chrome 131, Windows 11
Шаги: 1) открыть /checkout 2) нажать «Оформить» с пустым телефоном
Услышано: «Оформить, кнопка», далее тишина 5 секунд
Ожидалось: объявление ошибки, например «Ошибка: укажите телефон»
Критерий: WCAG 2.2, 3.3.1 Error Identification (A), 4.1.3 Status Messages (AA)
Скриншот Speech Viewer приложен.
Строка «Услышано / Ожидалось» превращает субъективное «неудобно» в воспроизводимый дефект с приёмочным критерием. Это, пожалуй, главный организационный приём всей статьи.
Что можно и чего нельзя автоматизировать
Уровень 1 — статические проверки дерева. axe-core ловит отсутствующие имена, неверные роли, aria-hidden на фокусируемом. Быстро, дёшево, покрывает примерно четверть проблем.
Уровень 2 — снимки дерева доступности. Playwright умеет сравнивать ARIA-снимок узла с эталоном (expect(locator).toMatchAriaSnapshot()): снимок фиксирует роли и доступные имена, а не разметку, поэтому рефакторинг вёрстки тест не сломает, а потеря подписи — сломает.
Уровень 3 — виртуальный скринридер в юнит-тестах. Пакет @guidepup/virtual-screen-reader реализует поведение скринридера поверх дерева доступности прямо в jsdom. Живой NVDA он не заменяет, но регрессии в объявлениях фиксирует отлично:
import { virtual } from '@guidepup/virtual-screen-reader';
test('кнопка удаления объявляется с понятным именем', async () => {
document.body.innerHTML = `
<button aria-label="Удалить стул Ergo из корзины">
<svg aria-hidden="true" focusable="false"></svg>
</button>`;
await virtual.start({ container: document.body });
await virtual.next();
// Точная формулировка зависит от версии пакета — сверяйтесь с её выводом
expect(await virtual.lastSpokenPhrase()).toContain('Удалить стул Ergo из корзины');
await virtual.stop();
});
Уровень 4 — настоящий NVDA и VoiceOver в CI. Библиотека Guidepup управляет реальными скринридерами на Windows- и macOS-раннерах и возвращает лог произнесённого. Имеет смысл для дизайн-системы или критичного сценария оформления заказа, где регрессия дорога.
И жёсткое ограничение, которое стоит проговаривать вслух на каждом обсуждении: автоматика проверяет, что объявление есть, и никогда — что оно осмысленно. alt="изображение" пройдёт все линтеры мира, aria-label="кнопка 3" тоже. Про соотношение автоматического и ручного — в статье про тестирование и процесс и в материале про фронтенд-тестирование.
Мифы, которые дорого стоят
- «Скринридер читает всё подряд, поэтому порядок DOM не важен». Наоборот: линейное чтение — редкий режим. Люди прыгают по заголовкам, ссылкам и ориентирам, и качество этой структуры важнее качества текста.
- «Добавим ARIA — станет доступно». ARIA меняет только то, что будет объявлено. Поведение — фокус, клавиши, порядок — целиком на вас, а неправильная ARIA хуже её отсутствия: она обещает то, чего нет.
- «Пользователь скринридера медленный, надо ему всё упростить». Обычная скорость речи опытного пользователя — 300–500 слов в минуту, разработчик такую запись не разберёт. Упрощать надо структуру, а не количество информации.
- «Оверлей-виджет решит проблему». Накладки, обещающие «доступность в одну строку скрипта», конфликтуют с настоящими скринридерами и регулярно делают хуже — см. Overlay Fact Sheet.
- «Это редкий кейс». Скринридером пользуются не только незрячие люди: слабовидящие вместе с увеличением, люди с дислексией, люди с временными травмами глаз.
Мини-итог
- Скринридер читает дерево доступности, а не экран. Не попало в дерево — не существует.
- У него три независимых курсора. Системный фокус — единственный, о котором знает браузер. Виртуальный курсор ходит там, куда
Tabне доберётся, — и уходит за модалку, если не поставитьinert. - Виртуальный буфер — снимок документа. Динамические изменения без событий оставляют человека читать прошлое.
- Роль — это контракт: она переключает режим и обещает клавиатурную модель, которую вы обязаны реализовать.
- Фраза собирается из сегментов: контекст, имя, роль, состояние, позиция, описание. Имя главное, и
aria-labelперекрывает видимый текст, ломая голосовое управление. - Изменение без объявления не существует. Живая область должна быть в DOM заранее, быть
politeи не тараторить. - Speech Viewer в NVDA и Caption Panel в VoiceOver превращают отладку из угадывания в чтение. Включите их сегодня.
- Пятнадцатиминутный прогон с закрытыми глазами по основному сценарию находит больше, чем любой линтер.
Источники
- WCAG 2.2 — прежде всего 1.3.1, 2.4.3, 2.4.6, 2.5.3, 3.3.1, 4.1.2 и 4.1.3.
- WAI-ARIA 1.2 и ARIA Authoring Practices Guide — роли и готовые паттерны компонентов.
- Accessible Name and Description Computation — алгоритм вычисления имени.
- MDN: ARIA live regions, Accessibility tree, inert.
- Руководство пользователя NVDA и руководство по VoiceOver.
- WebAIM Screen Reader User Survey, шпаргалки Deque University, таблицы поддержки a11ysupport.io.
- Блоги практиков: Scott O’Hara, Adrian Roselli, Sara Soueidan; Guidepup для тестов.
Что дальше
Скринридер — самый заметный, но не единственный способ потребления интерфейса. Дальше разберём тех, кто смотрит на экран, но иначе: с высоким увеличением, с низким зрением, с чувствительностью к движению.
Визуальная доступность: контраст, размер, масштабирование, анимация