Семантика для screen readers

Screen reader (экранный диктор) -- программа, которая читает содержимое страницы вслух, опираясь на семантику HTML. Правильная разметка обеспечивает полноценный доступ к контенту для пользователей с нарушением зрения.

Зачем нужно

Screen reader-ы (NVDA, JAWS, VoiceOver, TalkBack) -- основной инструмент незрячих пользователей. Они не «видят» страницу, а строят Accessibility Tree из HTML. Если разметка семантическая -- пользователь может перемещаться по заголовкам, спискам, формам, landmarks. Если всё на <div> -- страница превращается в непрерывный поток текста.

Где используется

  • Любой публичный сайт (юридическое требование во многих странах)
  • Государственные и образовательные ресурсы (WCAG 2.1 AA)
  • E-commerce и банковские приложения
  • SPA-приложения с динамическим контентом

Предпосылки

Как screen reader воспринимает страницу

Accessibility Tree

Браузер строит Accessibility Tree из DOM:

DOM:
<nav aria-label="Основная">
  <ul>
    <li><a href="/">Главная</a></li>
    <li><a href="/about" aria-current="page">О нас</a></li>
  </ul>
</nav>

Accessibility Tree:
navigation "Основная"
  list (2 items)
    listitem
      link "Главная"
    listitem
      link "О нас" (current page)

Screen reader озвучит: «Навигация, Основная. Список, 2 элемента. Ссылка, Главная. Ссылка, О нас, текущая страница.»

Landmarks -- ориентиры страницы

Landmarks позволяют screen reader-у быстро переключаться между секциями страницы:

<header>          <!-- banner -->
  <nav>           <!-- navigation -->
    ...
  </nav>
</header>
<main>            <!-- main -->
  <section>       <!-- region (с aria-label) -->
    <h1>...</h1>
  </section>
  <aside>         <!-- complementary -->
    ...
  </aside>
</main>
<footer>          <!-- contentinfo -->
  ...
</footer>
Быстрая навигация по landmarks (VoiceOver: Rotor → Landmarks):
- banner (header)
- navigation (nav)
- main (main)
- complementary (aside)
- contentinfo (footer)

Именование landmarks

Если на странице несколько <nav>, каждый должен иметь уникальное имя:

<!-- Две навигации -- обе нужно именовать -->
<nav aria-label="Основная навигация">
  <ul>...</ul>
</nav>

<nav aria-label="Подвал сайта">
  <ul>...</ul>
</nav>

Заголовки -- навигация по структуре

Screen reader-ы позволяют перемещаться по заголовкам (H → следующий заголовок). Правильная иерархия критична:

<!-- Правильно: логическая иерархия -->
<h1>Магазин электроники</h1>
  <h2>Смартфоны</h2>
    <h3>Apple</h3>
    <h3>Samsung</h3>
  <h2>Ноутбуки</h2>
    <h3>Игровые</h3>
    <h3>Для работы</h3>

<!-- Неправильно: пропуск уровней -->
<h1>Магазин</h1>
  <h4>Смартфоны</h4>  <!-- Пропущены h2, h3 -->
  <h2>Ноутбуки</h2>

Live regions -- динамические обновления

Screen reader не замечает DOM-изменения без live regions:

<!-- Уведомление при сохранении формы -->
<div role="status" aria-live="polite" id="form-status"></div>

<!-- Ошибка -- немедленно объявить -->
<div role="alert" aria-live="assertive" id="error-msg"></div>

<script>
  async function saveForm() {
    try {
      await fetch('/api/save', { method: 'POST', body: formData });
      document.getElementById('form-status').textContent = 'Данные сохранены';
      // SR озвучит: "Данные сохранены" (после текущей фразы)
    } catch (e) {
      document.getElementById('error-msg').textContent = 'Ошибка сохранения';
      // SR озвучит: "Ошибка сохранения" (немедленно)
    }
  }
</script>

aria-atomic и aria-relevant

<!-- aria-atomic="true": озвучить весь блок при любом изменении -->
<div aria-live="polite" aria-atomic="true" id="cart-total">
  Итого: <span id="price">0</span> руб.
</div>
<!-- При обновлении #price SR скажет: "Итого: 1500 руб." (весь блок) -->

<!-- aria-relevant: что отслеживать -->
<ul aria-live="polite" aria-relevant="additions removals">
  <!-- SR объявит добавление и удаление элементов -->
</ul>

Visually Hidden -- скрыть визуально, оставить для SR

/* Класс для визуального скрытия (доступен screen reader-у) */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
<!-- Кнопка с иконкой и скрытым текстом -->
<button>
  <svg aria-hidden="true"><!-- иконка корзины --></svg>
  <span class="visually-hidden">Добавить в корзину</span>
</button>

<!-- Skip link (первый элемент страницы) -->
<a href="#main-content" class="visually-hidden">
  Перейти к основному содержимому
</a>

<!-- Дополнительный контекст для SR -->
<table>
  <caption class="visually-hidden">Цены на тарифы</caption>
  ...
</table>

Не путать с display: none и visibility: hidden -- они скрывают элемент и от screen reader.

<body>
  <!-- Первый элемент -- skip link -->
  <a href="#main" class="skip-link">Перейти к содержимому</a>

  <header>
    <nav><!-- 20 ссылок навигации --></nav>
  </header>

  <main id="main" tabindex="-1">
    <!-- Основной контент -->
  </main>
</body>
.skip-link {
  position: absolute;
  top: -100%;
  left: 0;
  z-index: 1000;
  padding: 8px 16px;
  background: #000;
  color: #fff;
}

/* Показать при фокусе (Tab) */
.skip-link:focus {
  top: 0;
}

Формы и screen readers

<form>
  <!-- Группировка: fieldset + legend -->
  <fieldset>
    <legend>Контактная информация</legend>
    <!-- SR: "Контактная информация, группа" -->

    <!-- label привязан к input -->
    <label for="name">Имя:</label>
    <input type="text" id="name" required
           aria-describedby="name-hint">
    <p id="name-hint">Как к вам обращаться</p>
    <!-- SR: "Имя, обязательное текстовое поле. Как к вам обращаться" -->

    <label for="email">Email:</label>
    <input type="email" id="email" required
           aria-invalid="true"
           aria-describedby="email-error">
    <p id="email-error" role="alert">Введите корректный email</p>
    <!-- SR: "Email, обязательное, невалидное. Введите корректный email" -->
  </fieldset>

  <button type="submit">Отправить</button>
</form>

Изображения и медиа

<!-- Информативное изображение -->
<img src="chart.png" alt="График продаж: рост 25% за Q3 2025">

<!-- Декоративное изображение -->
<img src="divider.png" alt="" role="presentation">

<!-- Сложное изображение -->
<figure>
  <img src="diagram.png" alt="Архитектура микросервисов"
       aria-describedby="diagram-desc">
  <figcaption id="diagram-desc">
    Схема показывает 5 сервисов, связанных через API Gateway...
  </figcaption>
</figure>

<!-- Видео с субтитрами -->
<video controls>
  <source src="tutorial.mp4" type="video/mp4">
  <track kind="captions" src="captions-ru.vtt" srclang="ru" label="Русские">
</video>

Тестирование с screen readers

Screen Reader ОС Бесплатный Браузер
NVDA Windows Да Firefox, Chrome
JAWS Windows Нет Chrome, Edge
VoiceOver macOS / iOS Да (встроен) Safari
TalkBack Android Да (встроен) Chrome

Быстрое тестирование

1. NVDA (Windows): скачать с nvaccess.org → Insert+↓ (читать всё)
2. VoiceOver (Mac): Cmd+F5 → VO+→ (следующий элемент)
3. Chrome DevTools: Elements → Accessibility panel → Accessibility Tree
4. Расширение axe DevTools: запустить аудит

Частые ошибки

Ошибка Проблема для SR Решение
<div> вместо <button> SR не объявляет как кнопку Нативный <button>
Нет alt на <img> SR читает имя файла Добавить описательный alt
display: none для «скрытого» текста SR не увидит .visually-hidden класс
Нет landmarks Нет быстрой навигации <header>, <main>, <nav>
Динамический контент без live region SR не замечает изменения aria-live
Пропуск уровней заголовков Сломанная иерархия h1 → h2 → h3 последовательно

Практика

  1. Включи NVDA или VoiceOver и проведи 10 минут на своём сайте
  2. Добавь skip link как первый элемент страницы
  3. Проверь все изображения -- у каждого должен быть корректный alt
  4. Добавь aria-live region для уведомлений
  5. Проверь landmarks через Chrome Accessibility Tree

Связанные темы

Ресурсы