Перейти к основному содержимому
    Назад к блогу
    РазработкаОпубликовано 24 октября 2026 г.

    Доступность в React: id, фокус при смене маршрута и объявления

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

    Доступность в React: id, фокус при смене маршрута и объявления

    Часть 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,
      // ...остальные настройки
    ];
    

    Серия «Доступный веб на практике»

    1. Доступность сайта: для кого она на самом деле и с чего начать
    2. Семантический HTML и альтернативный текст: фундамент доступного сайта
    3. Доступные формы: подписи, подсказки, ошибки и автозаполнение
    4. Клавиатура и фокус: как сделать сайт, которым можно пользоваться без мыши
    5. Контраст, анимация и размер кнопок: визуальная доступность сайта
    6. ARIA и динамический контент: когда атрибуты помогают, а когда вредят
    7. Доступность в React: id, фокус при смене маршрута и объявления — вы здесь
    8. Как тестировать доступность: автоматика, ручная проверка и чеклист

    Следующая часть: Как тестировать доступность: автоматика, ручная проверка и чеклист

    Agama Labs — разработка сайтов и веб-приложений. Если хотите проверить доступность своего сайта, напишите нам: hello@agamalabs.com

    Контакты

    Давайте обсудим ваш проект

    Оставьте заявку, и мы свяжемся с вами в течение 2 часов для бесплатной консультации. Расскажите о вашей идее — поможем найти оптимальное решение.

    Время работы

    Пн–Пт: 08:00 – 17:00 (PST)
    Сб–Вс: Выходной

    Оставить заявку

    Заполните форму, и мы перезвоним вам

    Нажимая кнопку, вы соглашаетесь с политикой конфиденциальности

    Мы ценим вашу конфиденциальность

    Мы используем аналитические cookies, в том числе сторонних сервисов, чтобы понимать, как используется сайт, и улучшать его