Доступность в React: id, фокус при смене маршрута и объявления
useId для связей полей, управление фокусом в SPA, провайдер объявлений, возврат фокуса после удаления, готовые библиотеки и линтер jsx-a11y

Часть 7 из 8 серии «Доступный веб на практике». Предыдущая часть: ARIA и динамический контент: когда атрибуты помогают, а когда вредят.
React рендерит обычный DOM, поэтому всё из предыдущих частей применимо напрямую. Но у одностраничных приложений есть свои ловушки: уникальные id, фокус при переходах, объявления об изменениях. Разбираем их с готовым кодом.
Доступность в React
Синтаксические мелочи
// В JSX — htmlFor вместо for, а aria-* пишутся как есть
<label htmlFor="email">Электронная почта</label>
<input id="email" type="email" autoComplete="email" aria-describedby="email-hint" />
Не используйте <div>-обёртки там, где они ломают семантику: например, <ul> должен содержать только <li>. Для группировки без лишнего DOM — фрагменты <>...</>.
Уникальные id: useId
Связи htmlFor, aria-describedby, aria-labelledby требуют уникальных id. Если компонент рендерится на странице несколько раз, жёстко заданный id сломает связь. С React 18 есть useId:
import { useId } from 'react';
function TextField({ label, hint, error, ...props }) {
const id = useId();
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
const describedBy = [hint && hintId, error && errorId].filter(Boolean).join(' ') || undefined;
return (
<div className="field">
<label htmlFor={id}>{label}</label>
{hint && <p id={hintId} className="hint">{hint}</p>}
<input
id={id}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
{...props}
/>
{error && <p id={errorId} className="error">{error}</p>}
</div>
);
}
// Использование
<TextField label="Электронная почта" type="email" autoComplete="email"
hint="На этот адрес придёт подтверждение" error={errors.email} />
Фокус при смене маршрута
При переходе между страницами в SPA браузер не перезагружает страницу, и фокус остаётся на нажатой ссылке — или теряется. Пользователь скринридера не узнает, что страница сменилась. Распространённое решение — переводить фокус на заголовок новой страницы и обновлять document.title:
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
function PageHeading({ children, title }) {
const ref = useRef(null);
const { pathname } = useLocation();
useEffect(() => {
document.title = `${title} — Agama Labs`;
ref.current?.focus();
}, [pathname, title]);
return (
<h1 ref={ref} tabIndex={-1} className="page-heading">
{children}
</h1>
);
}
/* Заголовок получает фокус программно — обводку для него можно убрать */
.page-heading:focus { outline: none; }
Next.js App Router по умолчанию объявляет смену маршрута скринридерам через встроенный announcer (он зачитывает document.title, а если его нет — первый h1), поэтому там особенно важно задавать уникальный title для каждой страницы (Next.js: Accessibility).
Хук для объявлений
import { createContext, useCallback, useContext, useState } from 'react';
const AnnouncerContext = createContext(() => {});
export function AnnouncerProvider({ children }) {
const [message, setMessage] = useState('');
const announce = useCallback((text) => {
setMessage('');
setTimeout(() => setMessage(text), 100);
}, []);
return (
<AnnouncerContext.Provider value={announce}>
{children}
{/* Регион рендерится всегда, меняется только текст */}
<div role="status" aria-live="polite" className="visually-hidden">
{message}
</div>
</AnnouncerContext.Provider>
);
}
export const useAnnounce = () => useContext(AnnouncerContext);
// В компоненте
function AddToCart({ product }) {
const announce = useAnnounce();
return (
<button type="button" onClick={async () => {
await addToCart(product.id);
announce(`${product.name} добавлен в корзину`);
}}>
В корзину
</button>
);
}
Возврат фокуса после удаления
Если пользователь удалил элемент списка кнопкой внутри этого элемента, кнопка исчезает вместе с фокусом, и фокус «падает» в начало страницы. Переведите его на соседний элемент или на заголовок списка:
function onDelete(index) {
removeItem(index);
// после рендера — на следующий элемент, либо на предыдущий, либо на заголовок
requestAnimationFrame(() => {
const next = itemRefs.current[index] ?? itemRefs.current[index - 1] ?? listHeadingRef.current;
next?.focus();
});
}
Не изобретайте сложные компоненты
Комбобокс с автодополнением, выбор даты, меню, вкладки, drag-and-drop — написать их доступными с нуля очень трудно. Есть библиотеки, где эта работа уже сделана и проверена на скринридерах:
- React Aria (Adobe) — хуки и компоненты без стилей, с самой подробной проработкой доступности, включая мобильные скринридеры;
- Radix UI — примитивы без стилей, на них построен shadcn/ui;
- Headless UI — от авторов Tailwind CSS.
Линтер
eslint-plugin-jsx-a11y ловит ошибки прямо при написании кода: img без alt, onClick на div без клавиатурного обработчика, неверные ARIA-атрибуты.
npm install --save-dev eslint-plugin-jsx-a11y
// eslint.config.js (flat config)
import jsxA11y from 'eslint-plugin-jsx-a11y';
export default [
jsxA11y.flatConfigs.recommended,
// ...остальные настройки
];
Серия «Доступный веб на практике»
- Доступность сайта: для кого она на самом деле и с чего начать
- Семантический HTML и альтернативный текст: фундамент доступного сайта
- Доступные формы: подписи, подсказки, ошибки и автозаполнение
- Клавиатура и фокус: как сделать сайт, которым можно пользоваться без мыши
- Контраст, анимация и размер кнопок: визуальная доступность сайта
- ARIA и динамический контент: когда атрибуты помогают, а когда вредят
- Доступность в React: id, фокус при смене маршрута и объявления — вы здесь
- Как тестировать доступность: автоматика, ручная проверка и чеклист
Следующая часть: Как тестировать доступность: автоматика, ручная проверка и чеклист
Agama Labs — разработка сайтов и веб-приложений. Если хотите проверить доступность своего сайта, напишите нам: hello@agamalabs.com


