lenec ru

← все посты

Семантика для SPA: landmarks и что ломается без них

18K

Когда я пришла во фронтенд из бэкенда, меня смущала одна вещь. Все писали SPA на React, обвешивали страницу div'ами, и никто не задумывался, что страница, по сути, потеряла структуру. В классическом MPA структура была встроена — header, main, aside, footer. В SPA эти теги часто заменяются на безымянные обёртки. Это работает визуально, но ломается, как только пользователь приходит со скринридером.

Эта статья — про то, что такое landmarks, почему они особенно важны в SPA, и какие частные случаи путают даже опытных разработчиков. Без воды и копипасты из спецификации W3C.

Что такое landmarks

Landmark — это участок страницы с определённой ролью. ARIA Landmark Roles — это banner, navigation, main, complementary, contentinfo, search, form, region. Большинство из них имеют HTML-эквиваленты:

  • <header> внутри <body> = banner
  • <nav> = navigation
  • <main> = main
  • <aside> = complementary
  • <footer> внутри <body> = contentinfo
  • <form aria-label="..."> = form
  • <section aria-label="..."> = region

Скринридер использует landmarks для быстрой навигации по странице. На VoiceOver — Ctrl+Option+U открывает rotor, в нём вкладка Landmarks. На NVDA — клавиша D перепрыгивает между ними. Без landmarks такая навигация невозможна, и пользователь вынужден слушать всю страницу подряд.

Почему именно SPA проблематичен

Три причины, по которым в SPA с landmarks обычно беда.

Первая — компонентный подход. У тебя есть <Layout>, который оборачивает страницы. Внутри него <Header> и <Footer> на каждой странице. А <Page> — какой-то generic-компонент, в котором по умолчанию div. Никто не проследил, чтобы эти div-ы были main.

Вторая — переиспользование. Дизайн-система отдаёт компонент <Card>. Кто-то использует его как обёртку для всей страницы. Кто-то — как маленькую плитку в списке. Семантически это разные вещи. Если внутри Card сидит <section> или, ещё хуже, <article>, на странице оказывается десять article'ов, а main всё равно нет.

Третья — роутинг. После клика по ссылке URL меняется, но фокус остаётся на ссылке. Содержимое страницы переписалось, а скринридер не сообщает об этом. Если landmarks правильно расставлены — у пользователя хотя бы есть способ быстро вернуться к main. Если их нет — он переходит на новую страницу и не может понять, где он находится.

Минимальный набор для любой страницы

<body>
  <a href="#main" class="skip-link">Перейти к содержимому</a>
  <header>
    <a href="/">Логотип</a>
    <nav aria-label="Главная">
      <ul>
        <li><a href="/">Лента</a></li>
        <li><a href="/about">О нас</a></li>
      </ul>
    </nav>
  </header>
  <main id="main">
    <h1>Заголовок страницы</h1>
    ...
  </main>
  <footer>
    <p>© 2026</p>
  </footer>
</body>

Я подчёркиваю: id="main" на main — это не для CSS. Это якорь для skip link, чтобы пользователь клавиатуры мог пропустить навигацию. Без этого atribute id main теряет смысл как landmark — ну то есть остаётся, но пропустить к нему через Tab никак.

Несколько nav на странице

Самый частый сложный случай. У тебя есть главная навигация в шапке, навигация в сайдбаре и подвал с второстепенными ссылками. Если все три — <nav> без атрибутов, скринридер озвучит их как «navigation, navigation, navigation». Бесполезно.

Решение — у каждого nav свой ярлык:

<nav aria-label="Главная">...</nav>
<nav aria-label="Разделы документации">...</nav>
<nav aria-label="Подвал">...</nav>

Слово «навигация» в названии не нужно — скринридер сам добавит «navigation» к лейблу. aria-label="Главная навигация" прозвучит как «Главная навигация navigation», избыточно.

Когда не использовать main

На странице должен быть ровно один <main>. Не два, не ноль. Если у тебя в layout-компоненте уже есть main, не вставляй ещё один в страницу. Если у тебя его вообще нет в layout — это баг, чини.

Я часто вижу попытки сделать главные раздел страницы через <section> с role="main". Не надо. Используй настоящий тег. Все современные скринридеры понимают main без role.

Section vs region vs article

Эти три тега путают чаще всего. Различаю так:

  • <article> — самостоятельный фрагмент контента, который имел бы смысл сам по себе. Пост в ленте, новость, комментарий, карточка товара в каталоге. Если можно взять и опубликовать отдельно — это article.
  • <section> — тематическая группировка контента внутри страницы. По спецификации она не является landmark по умолчанию. Становится region только с aria-label или aria-labelledby. Без лейбла скринридер просто игнорирует тег как landmark.
  • <aside> — вспомогательный контент, связанный с основным, но не критичный. Сайдбар с related-постами, врезка с цитатой, рекламный блок. Это автоматически landmark complementary.

Если у тебя <section> без лейбла — это просто визуальная группировка, и <div> с тем же успехом подошёл бы. Поэтому либо ставь лейбл, либо не используй section.

Search и form

Поиск на сайте — отдельный landmark. Многие об этом забывают и оборачивают форму поиска в обычный nav.

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

HTML5 не имеет тега <search> до недавнего времени, поэтому используем атрибут role. На самом деле в 2023 в HTML добавили тег <search>, но он пока не везде поддержан скринридерами идеально.

Обычная форма (логин, отправка комментария) становится landmark, только если у неё есть aria-label. Без него <form> — обычный элемент, не region.

Что ломается в SPA после переходов

Когда роутер обновляет страницу, у пользователя со скринридером ничего не происходит. Озвучка — нет, фокус — нет. Это нужно править руками.

Минимум: после смены роута устанавливать фокус на main или на h1. Тогда скринридер начнёт зачитывать содержимое.

import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";

export function FocusOnRouteChange() {
  const location = useLocation();
  const ref = useRef<HTMLElement | null>(null);

  useEffect(() => {
    ref.current = document.querySelector("main");
    if (ref.current) {
      ref.current.setAttribute("tabindex", "-1");
      ref.current.focus();
    }
  }, [location.pathname]);

  return null;
}

Атрибут tabindex="-1" делает main фокусируемым программно, но не доступным через Tab. Это важно, чтобы Tab по странице работал как обычно.

Дополнительно — обновление title через document.title или через хелмет/head-библиотеку. Скринридер озвучит новый title после фокуса.

Как проверить, что у меня всё нормально

Есть три способа.

Linter

Установи eslint-plugin-jsx-a11y. У него есть правила jsx-a11y/no-redundant-roles (запрет role="main" на <main>) и jsx-a11y/role-has-required-aria-props (поймает role="navigation" без aria-label, если их два).

DevTools Accessibility tree

В Chrome DevTools на вкладке Elements есть подвкладка Accessibility. В ней — accessibility tree страницы. Развернёшь — увидишь, какие landmark'и распознал браузер. Если main внутри другого main — увидишь.

Skip-навигация скринридера

На macOS — VoiceOver через Cmd+F5. Запусти, открой страницу, нажми Ctrl+Option+U, перейди в Landmarks. Должен быть короткий внятный список: banner, navigation (один-два), main, contentinfo. Если там десять регионов с одинаковыми именами — что-то сломано.

Что унести с собой

В SPA semantic markup — это не про красоту кода, а про то, чтобы страница вообще работала для людей с клавиатурой и скринридером. Минимум — banner (через header), navigation (с aria-label), main (с id для skip), contentinfo (через footer). Этого достаточно, чтобы интерфейс перестал быть «безымянной кашей» для assistive technology.

Дальше — отдельные правила для динамических обновлений: aria-live для уведомлений, aria-atomic для счётчиков, фокус-менеджмент при роутинге. Но landmarks — фундамент. Без них всё остальное бесполезно.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.