Семантика для SPA: landmarks и что ломается без них
Когда я пришла во фронтенд из бэкенда, меня смущала одна вещь. Все писали 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-постами, врезка с цитатой, рекламный блок. Это автоматически landmarkcomplementary.
Если у тебя <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 — фундамент. Без них всё остальное бесполезно.