Focus management в SPA: куда возвращать фокус после навигации
Когда переходишь между страницами в обычном сайте — браузер сам ставит фокус в начало документа, и скринридер начинает читать сначала. В SPA URL меняется, а DOM нет — браузер не понимает, что произошла навигация, и фокус остаётся там, где был. Для пользователя со скринридером это выглядит как «я кликнула по ссылке, а ничего не произошло».
Расскажу, как я делаю фокус-менеджмент в SPA-приложениях на React (Next.js, Remix, чистый React Router), какие границы достаточно покрыть, и где обычно начинаются грабли.
Что должно происходить при навигации
Минимальное правило: после смены маршрута фокус должен оказаться там, где имеет смысл начать чтение. Обычно это:
- Заголовок страницы (
h1). - Главный контент-контейнер (
main). - Вершина страницы, если контент сложный.
Альтернатива — переместить фокус на skip-link или на кнопку «Назад». В большинстве случаев самое понятное поведение — фокус на заголовок страницы.
Базовая реализация для Next.js App Router
В App Router смена маршрута ловится через usePathname. Делаешь маленький компонент-наблюдатель в layout-е:
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";
export function RouteFocus() {
const pathname = usePathname();
const isFirst = useRef(true);
useEffect(() => {
if (isFirst.current) {
isFirst.current = false;
return;
}
const target = document.querySelector<HTMLElement>("h1")
?? document.querySelector<HTMLElement>("main");
target?.focus();
}, [pathname]);
return null;
}
Что важно: на первый рендер не двигаем фокус. Браузер уже поставил его правильно (в адресную строку или в начало документа), любое перемещение из-под React будет помехой.
tabIndex=-1 на заголовке
По умолчанию h1 и main не получают фокус. Чтобы туда можно было программно сфокусироваться, нужно поставить tabIndex={-1}:
<main tabIndex={-1}>
<h1 tabIndex={-1}>{title}</h1>
{children}
</main>
Значение -1 означает: «фокусироваться программно можно, через Tab — нельзя». Это правильное поведение: пользователь не должен случайно попасть на заголовок Tab-ом, но мы можем послать туда фокус из кода.
Без tabIndex вызов .focus() на заголовке ничего не сделает.
Озвучивание заголовка скринридером
После фокусировки на заголовке скринридер прочитает его текст. Это и есть та «обратная связь», которую мы хотим: пользователь нажал ссылку — услышал название новой страницы.
Если хочется большего — использую aria-live-регион с информацией о навигации:
<div role="status" aria-live="polite" className="sr-only">
Перешли на страницу {title}
</div>
Этот регион невидимый, но скринридер прочитает его текст при изменении. Сочетание «фокус на заголовке + live-регион» работает в большинстве комбинаций ОС/скринридер/браузер.
Возврат фокуса в модалках
Отдельная история. Когда открываешь модалку — фокус должен уйти внутрь. Когда закрываешь — вернуться на кнопку, которая её открыла.
function Modal({ open, onClose, children }: Props) {
const triggerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (open) {
triggerRef.current = document.activeElement as HTMLElement;
return;
}
triggerRef.current?.focus();
}, [open]);
return open ? <div role="dialog" aria-modal="true">{children}</div> : null;
}
Идея: при открытии запоминаем, кто был сфокусирован. При закрытии возвращаем фокус туда. Если этого не сделать — фокус «убежит» в начало body, и пользователь потеряется.
Готовые библиотеки модалок (radix-ui, react-aria) делают это сами. Если у тебя своё — обязательно реализуй.
Focus trap внутри модалки
Не менее важная штука: пока модалка открыта, Tab не должен «убегать» наружу. Иначе пользователь нажимает Tab несколько раз и попадает на элементы под модалкой, которые ещё и интерактивны.
Это решается через focus trap — компонент или хук, который ловит Tab/Shift+Tab и циклически перемещает фокус внутри модалки. Я беру focus-trap-react или встроенные механизмы radix-ui. Свою реализацию писать не советую — там много нюансов с iframe, dynamic-контентом, скрытыми элементами.
Управление фокусом после удаления элемента
Часто упускаемая деталь. У тебя список из 10 элементов, у каждого кнопка «Удалить». Пользователь нажимает кнопку. Элемент удалён. Фокус был на кнопке — теперь её нет, фокус улетел в body.
Что делать: перед удалением запомнить, куда переместить фокус (на следующий элемент, на предыдущий, на кнопку «Удалить» соседнего элемента), и после рендера переместить фокус туда.
const onDelete = (index: number) => {
const next = items[index + 1] ?? items[index - 1];
removeItem(index);
if (next) {
requestAnimationFrame(() => {
document.getElementById(`item-${next.id}`)?.focus();
});
}
};
requestAnimationFrame нужен, чтобы дождаться следующего рендера, когда DOM уже обновлён.
Динамический контент: aria-live или фокус?
Когда на странице появляется новое содержимое (например, после клика «Загрузить ещё»), есть выбор: переместить туда фокус или анонсировать через aria-live.
- Фокус. Пользователь оказывается в новом контенте сразу. Подходит для крупных изменений (открытие диалога, переход в редактор).
- aria-live. Скринридер озвучивает изменение, но фокус остаётся там, где был. Подходит для статусов (форма отправлена, новое уведомление).
Не делай и то, и другое одновременно. Получится «шум» и пользователь не поймёт, что произошло.
Skip-link: для клавиатурных пользователей
В начале страницы — невидимая ссылка «Перейти к содержимому», которая становится видимой при фокусе:
<a href="#main" className="skip-link">
Перейти к содержимому
</a>
<header>...</header>
<main id="main" tabIndex={-1}>...</main>
.skip-link {
position: absolute;
left: -9999px;
}
.skip-link:focus {
left: 8px;
top: 8px;
}
Чтобы клавиатурный пользователь мог перепрыгнуть длинное меню и попасть сразу в контент. На SPA-сайтах с большим header-ом это особенно важно — без skip-link нужно нажать Tab 30 раз, чтобы дойти до основного контента.
Что я не делаю
Не двигаю фокус при back/forward
Если пользователь нажал «Назад» в браузере — это не навигация, инициированная пользователем по клику. Я не сбрасываю фокус, потому что в этом случае логичнее вернуть состояние страницы как было.
Не двигаю фокус при изменении query-параметров
Фильтрация на странице каталога меняет URL через query, но это не «новая страница» — это уточнение. Фокус оставляю.
Не делаю свой focus trap
Я однажды попыталась написать свой trap — ушло три дня и три бага в продакшене. Готовые библиотеки решают это лучше.
Тестирование
Минимум два прохода:
- Только клавиатура. Пройди по странице Tab-ом. На каждом шаге фокус виден? Можно ли активировать каждый интерактив с клавиатуры? Где-то фокус «теряется»?
- Скринридер. На macOS включи VoiceOver (Cmd+F5), на Windows — NVDA. Послушай, как читается страница. Особенно — что происходит при навигации между маршрутами.
Без живого тестирования автоматические инструменты (axe, Lighthouse) дают только базовый уровень. Их нужно дополнять руками.
Что запомнить
Focus management в SPA — это компенсация того, что браузер сам не знает про твою навигацию. Правило простое: после клика на ссылку фокус должен оказаться там, где пользователь начнёт «новую тему». Обычно это заголовок страницы. На модалках, формах, удалении элементов — отдельные правила, но логика та же: не оставляй фокус «висящим в воздухе».
И не пиши свой focus trap. Возьми готовый. Освободи время для дел, в которых ты приносишь больше пользы.