Ориентиры, заголовки и skip-link

Зрячий человек находит нужный блок за долю секунды. Разбираемся, как дать ту же скорость тому, кто страницу не видит.

Ориентир (landmark) — крупная именованная область страницы: шапка, навигация, основное содержимое, сайдбар, подвал. Скринридер умеет строить из ориентиров список и прыгать между ними одной клавишей — это аналог того, как вы «схватываете» структуру страницы глазами.

Зачем это на практике

Откройте любой интернет-магазин. Шапка, меню на 30 пунктов, баннер, фильтры, товары. Вы смотрите на страницу и мгновенно перескакиваете взглядом к товарам, игнорируя всё остальное. Человек со скринридером слышит страницу линейно, сверху вниз: логотип, поиск, 30 пунктов меню, баннер, фильтры… и только потом товары. Если бы навигация была возможна только так, каждый переход по сайту означал бы полторы минуты прослушивания одного и того же меню.

Поэтому скринридеры дают три способа быстрой навигации, и все три вы включаете разметкой:

  • по ориентирам — в NVDA клавиша D, в VoiceOver — ротор, раздел «Ориентиры»;
  • по заголовкам — клавиша H16 для конкретного уровня);
  • по ссылкам, кнопкам, полям формы — отдельные списки.

Именно эти списки и есть настоящая карта сайта для незрячего. Если на странице сплошные <div> — все три списка пусты, и остаётся единственный способ: слушать всё подряд.

Ориентиры: разметка и именование

Хорошая новость: ориентиры почти целиком берутся из тегов, которые мы разобрали в первом уроке.

<body>
  <a href="#content" class="skip-link">Перейти к содержимому</a>

  <header>                                <!-- ориентир banner -->
    <a href="/">CodeChick</a>

    <nav aria-label="Основное меню">      <!-- ориентир navigation -->
      <ul>
        <li><a href="/courses">Курсы</a></li>
        <li><a href="/challenges">Задачи</a></li>
      </ul>
    </nav>

    <form role="search" action="/search">  <!-- ориентир search -->
      <label for="q">Поиск по сайту</label>
      <input id="q" name="q" type="search">
      <button type="submit">Найти</button>
    </form>
  </header>

  <main id="content" tabindex="-1">       <!-- ориентир main -->
    <h1>Каталог курсов</h1>
    <article>
      <h2>Python с нуля</h2>
      <p>24 урока, 60 задач.</p>
    </article>
  </main>

  <aside aria-label="Похожие курсы">     <!-- ориентир complementary -->
    <h2>Смотрите также</h2>
  </aside>

  <footer>                               <!-- ориентир contentinfo -->
    <nav aria-label="Документы">
      <a href="/terms">Условия</a>
    </nav>
  </footer>
</body>

Список ориентиров, который получит пользователь, выглядит так:

Результат:

Ориентиры (6):
  1. баннер
  2. навигация «Основное меню»
  3. поиск
  4. основное
  5. дополнительное «Похожие курсы»
  6. нижний колонтитул
     навигация «Документы»

Обратите внимание на aria-label у обоих <nav>. Навигаций на странице обычно несколько (главное меню, хлебные крошки, подвал), и без имён пользователь услышит «навигация, навигация, навигация» — выбирать наугад. Правило: если ориентир такого типа на странице не один, дайте каждому имя. И не пишите в aria-label слово «навигация» — скринридер добавит роль сам, получится «навигация Навигация по сайту».

Второе правило: весь значимый контент должен лежать внутри какого-нибудь ориентира. Текст, оказавшийся между </header> и <main>, при навигации по ориентирам будет попросту пропущен.

Заголовки как второй слой навигации

Ориентиры дают крупные области, заголовки — содержание внутри них. Список заголовков — это оглавление, и оно должно читаться осмысленно даже в отрыве от страницы:

Результат:

Заголовки:
  1  Каталог курсов
    2  Python с нуля
    2  HTML и CSS
    2  Смотрите также

Если какой-то важный блок визуально «понятен без подписи» (например, список фильтров слева), для скринридера он всё равно безымянный. Здесь помогает визуально скрытый заголовок — он есть в разметке и в дереве доступности, но не виден на экране:

<section aria-labelledby="filters-title">
  <h2 id="filters-title" class="visually-hidden">Фильтры каталога</h2>
  <!-- чекбоксы фильтров -->
</section>
/* прячем от глаз, но НЕ от скринридера */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

Важно: display: none и visibility: hidden здесь не подходят — они убирают элемент и из дерева доступности тоже. Скрывать «только визуально» нужно именно так, через вынос за пределы видимой области.

Skip-link: ссылка «Перейти к содержимому»

Пользователь клавиатуры не может «перепрыгнуть взглядом» через меню — он жмёт Tab и проходит все 30 пунктов. На каждой странице. Skip-link — это первая в порядке табуляции ссылка, которая переносит фокус сразу в <main>.

<body>
  <a href="#content" class="skip-link">Перейти к содержимому</a>
  <header>...</header>
  <main id="content" tabindex="-1">
    <h1>Каталог курсов</h1>
  </main>
</body>
.skip-link {
  position: absolute;
  top: -100px;              /* спрятана над экраном */
  left: 1rem;
  z-index: 100;
  padding: 0.75rem 1rem;
  background: #fff;
  color: #111;
  border: 2px solid #111;
}

.skip-link:focus {
  top: 1rem;                /* появляется при фокусе с клавиатуры */
}

Три детали, которые чаще всего забывают:

  • Ссылка должна быть первой в DOM — иначе до неё придётся дотабаться через полшапки, и смысл теряется.
  • Она обязана становиться видимой при фокусе. Невидимая skip-link — ловушка для зрячего клавиатурного пользователя: фокус уехал непонятно куда.
  • tabindex="-1" на <main>. Без него часть браузеров прокручивает страницу к якорю, но фокус оставляет в начале документа — и следующий Tab вернёт человека обратно в меню. Атрибут tabindex="-1" делает элемент фокусируемым программно, не добавляя его в порядок табуляции.

Порядок чтения: его задаёт DOM, а не CSS

Скринридер и клавиатура идут по порядку в разметке. Глаз идёт по порядку на экране. Обычно они совпадают — но CSS умеет их развести, и тогда интерфейс разваливается.

/* опасно: визуально блоки поменялись местами, в DOM — нет */
.sidebar { order: -1; }              /* сайдбар нарисован слева, но читается последним */
.row     { flex-direction: row-reverse; }

Получается разрыв: человек видит сайдбар первым, а Tab уводит его туда в самом конце. Для незрячего порядок вообще другой. Спецификация WCAG называет это нарушением критерия «Значимая последовательность»: если визуальный порядок важен для понимания, он должен совпадать с порядком в DOM. Меняйте местами блоки в разметке, а не переставляйте их визуально.

Из той же оперы — положительный tabindex:

<!-- почти всегда ошибка: ломает естественный порядок обхода всей страницы -->
<input tabindex="3">
<input tabindex="1">

Допустимых значений практически два: 0 (обычный порядок) и -1 (фокус только из кода). Всё остальное — попытка руками управлять тем, что и так работает правильно, если разметка в верном порядке.

Как это работает

Ориентиры — это те же роли из дерева доступности, только «крупные»: banner, navigation, main, complementary, contentinfo, search, region, form. Скринридер обходит дерево, собирает все узлы с такими ролями и складывает в отдельный список — тот самый, что открывается по D или через ротор. Имя ориентира берётся из aria-label, из aria-labelledby или из заголовка внутри. Точно так же собирается список заголовков — по узлам с ролью heading и их уровням.

Одна тонкость: <header> и <footer> становятся ориентирами banner и contentinfo, только если они не вложены в <article>, <section> или <main>. Подвал внутри карточки товара — просто подвал карточки, и это правильно: иначе на странице оказалось бы двадцать «нижних колонтитулов».

Проверить всё это можно без скринридера. В Chrome DevTools включите Elements → Accessibility → Full-page accessibility tree — увидите ровно тот список, что получит пользователь. Расширения вроде Landmarks или Accessibility Insights рисуют ориентиры прямо поверх страницы.

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

ОшибкаПоследствие
Несколько <nav> без aria-label«навигация, навигация, навигация» — не выбрать нужную
Контент вне ориентировБлок выпадает из навигации по landmarks
Skip-link спрятана через display: noneСсылка не фокусируется — её просто нет
Skip-link не показывается при :focusЗрячий клавиатурный пользователь теряет фокус
Нет tabindex="-1" на цели skip-linkСтраница прокрутилась, но фокус остался в шапке
Визуальный порядок задан order / row-reverseПорядок табуляции не совпадает с картинкой
Положительный tabindexХаотичный обход всей страницы
Пустой <section> без имениОриентира не создаёт — только видимость семантики

Итоги

  • Скринридер навигирует тремя списками: ориентиры, заголовки, ссылки. Все три вы наполняете разметкой.
  • header, nav, main, aside, footer, form role="search" — готовые ориентиры без единой строчки ARIA.
  • Одинаковых ориентиров несколько — именуйте через aria-label, без слова «навигация» в тексте.
  • Весь значимый контент — внутри ориентиров; ничего не должно «висеть» между ними.
  • Skip-link: первая ссылка в DOM, видимая при фокусе, цель — <main id="content" tabindex="-1">.
  • Скрывать «только визуально» — классом .visually-hidden, а не display: none.
  • Порядок чтения задаёт DOM. CSS-перестановки (order, row-reverse) и положительный tabindex его ломают.
Проверьте себя
1. На странице три элемента nav: главное меню, хлебные крошки и меню подвала. Что нужно сделать?
AОставить только один nav — больше одного использовать нельзя
BДать каждому уникальное имя через aria-label, иначе пользователь услышит три одинаковых «навигация»
CДобавить каждому role="navigation" — теги nav сами по себе ориентиров не создают
DЗаменить два лишних nav на div, чтобы не засорять список ориентиров
2. Почему skip-link нельзя прятать через display: none и показывать только по :focus?
AПотому что display: none убирает элемент из дерева доступности и из порядка табуляции — ссылка перестаёт существовать и сфокусировать её нечем
BПотому что display: none работает не во всех браузерах
CПотому что скринридеры игнорируют любые CSS-свойства
DПотому что при display: none ссылка теряет href