lenec ru

← все посты

Focus management в SPA: куда возвращать фокус после навигации

11K

Когда переходишь между страницами в обычном сайте — браузер сам ставит фокус в начало документа, и скринридер начинает читать сначала. В 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 — ушло три дня и три бага в продакшене. Готовые библиотеки решают это лучше.

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

Минимум два прохода:

  1. Только клавиатура. Пройди по странице Tab-ом. На каждом шаге фокус виден? Можно ли активировать каждый интерактив с клавиатуры? Где-то фокус «теряется»?
  2. Скринридер. На macOS включи VoiceOver (Cmd+F5), на Windows — NVDA. Послушай, как читается страница. Особенно — что происходит при навигации между маршрутами.

Без живого тестирования автоматические инструменты (axe, Lighthouse) дают только базовый уровень. Их нужно дополнять руками.

Что запомнить

Focus management в SPA — это компенсация того, что браузер сам не знает про твою навигацию. Правило простое: после клика на ссылку фокус должен оказаться там, где пользователь начнёт «новую тему». Обычно это заголовок страницы. На модалках, формах, удалении элементов — отдельные правила, но логика та же: не оставляй фокус «висящим в воздухе».

И не пиши свой focus trap. Возьми готовый. Освободи время для дел, в которых ты приносишь больше пользы.

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

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

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