React accessibility (a11y) основы: семантика, клавиатура, ARIA и типичные ошибки

Разбираем основы React accessibility: как строить доступные компоненты, когда нужны ARIA-атрибуты, как работать с фокусом, формами и модалками и что отвечать на собеседовании.

13 апреля 2026 г.19 минLexicon Team

Введение

Тема React accessibility обычно возникает как технический долг, а в итоге упирается в вопрос качества продукта. Пока интерфейсом пользуются только с помощью мыши и на широком экране, проблема незаметна. Но как только в продукте появляются формы, модалки, кастомные селекты, таблицы, ошибки валидации и потоковые обновления, слабая доступность быстро приводит к багам: фокус теряется, screen reader читает мусор, кнопка оказывается недоступной с клавиатуры, а пользователь не понимает, что произошло после submit.

В React эта тема особенно коварна, потому что библиотека позволяет легко собрать красивый UI из абстракций, но не заставляет сохранять семантику. Команда пишет div с onClick, собирает compound-компонент, переносит слой через portal, добавляет состояние, а потом выясняется, что виджет удобен только для пользователя, который видит экран и пользуется мышью. Похожая проблема уже обсуждалась в материале про compound components pattern в React: гибкий API еще не гарантирует доступное поведение.

Эта статья разбирает React accessibility на базовом, но при этом инженерном уровне: что стоит считать минимумом, где достаточно нативного HTML, где действительно нужен ARIA, как проектировать фокус и почему a11y нельзя приклеить к приложению в самом конце.

Больше вопросов в Telegram

Ежедневные разборы и реальные кейсы с интервью

Подписаться

Что входит в базу React accessibility

Под основами accessibility обычно имеют в виду не весь стандарт WCAG целиком, а набор практик, без которых интерфейс становится непригодным для реальных пользователей.

Семантический HTML важнее любой обертки

Первый и самый простой выигрыш в a11y почти всегда связана не с React API, а с выбором тега. Если элемент запускает действие, это button. Если элемент ведет на другую страницу, это a. Если есть список, это ul или ol. Если есть заголовок раздела, он должен быть заголовком, а не стилизованным div.

Это кажется банальным, но именно здесь команды теряют доступность раньше всего. Причина проста: кастомный компонент маскирует исходный HTML. В JSX легко забыть, что под красивым <ActionCard /> внутри оказался div, который нельзя активировать с клавиатуры (клавишами Enter и Space).

Клавиатурная навигация должна быть рабочей по умолчанию

Пользователь должен иметь возможность пройти ключевой сценарий без мыши: открыть меню, перейти по ссылке, заполнить форму, закрыть модалку, отправить данные. Если виджет работает только по клику, это не edge case, а дефект.

Минимальная проверка выглядит так:

  • Tab проходит по интерактивным элементам в логичном порядке;
  • видимый focus state не исчезает из-за агрессивного CSS;
  • Enter и Space работают там, где ожидаются;
  • после закрытия модалки или поповера фокус возвращается в понятную точку.

ARIA не заменяет семантику

Частая ошибка junior и middle-разработчиков: увидеть проблему доступности и сразу пытаться исправить ее aria-* атрибутами. На практике порядок другой:

  1. Сначала нативный HTML.
  2. Потом корректная структура заголовков, форм и кнопок.
  3. Только затем ARIA для состояний, связей и сложных виджетов.

Если кнопка сделана через div, role="button" исправит ситуацию лишь отчасти. Придется руками восстанавливать клавиатурное поведение, tabIndex, состояния disabled и другие детали. Намного проще и надёжнее начать с настоящего button.

Как выглядит доступный React-компонент на практике

Хороший React-компонент решает не только визуальную задачу. Он должен одновременно давать:

  • корректный HTML на выходе;
  • понятное accessible name;
  • предсказуемые состояния disabled, invalid, expanded, selected;
  • связь между label, help text и error text;
  • стабильное поведение при ререндерах.

Для форм это особенно заметно. Если поле сложное, полезно параллельно держать в голове модель из статьи про controlled vs uncontrolled компоненты: источник истины влияет не только на UX, но и на то, когда и как вы объявляете ошибки, touched-состояния и live updates.

Пример: поле ввода с подсказкой и ошибкой

import { useId } from "react";

type TextFieldProps = {
  label: string;
  name: string;
  value: string;
  error?: string;
  hint?: string;
  onChange: (value: string) => void;
};

export function TextField({
  label,
  name,
  value,
  error,
  hint,
  onChange,
}: TextFieldProps) {
  const inputId = useId();
  const hintId = `${inputId}-hint`;
  const errorId = `${inputId}-error`;

  const describedBy = [hint ? hintId : null, error ? errorId : null]
    .filter(Boolean)
    .join(" ");

  return (
    <div>
      <label htmlFor={inputId}>{label}</label>
      <input
        id={inputId}
        name={name}
        value={value}
        aria-invalid={Boolean(error)}
        aria-describedby={describedBy || undefined}
        onChange={(event) => onChange(event.target.value)}
      />
      {hint ? <p id={hintId}>{hint}</p> : null}
      {error ? (
        <p id={errorId} role="alert">
          {error}
        </p>
      ) : null}
    </div>
  );
}

Здесь важны не сами атрибуты по отдельности, а связь между ними. label дает полю имя. aria-describedby связывает поле с подсказкой и ошибкой. aria-invalid сообщает о состоянии. role="alert" нужен не всегда, но полезен там, где ошибка появляется динамически и должна быть озвучена сразу.

Слабый вариант этого же компонента обычно выглядит так: placeholder вместо label, красная рамка без текстового сообщения и error text, который визуально виден, но никак не связан с input.

Архитектура доступности в React-приложении

Если смотреть на a11y как на системную задачу, то проблема редко ограничивается одним JSX-узлом. Обычно есть несколько слоев ответственности.

Компоненты, состояние и DOM-выход

Архитектурно доступность в React проходит через такую цепочку:

  1. Дизайн-система или UI-kit определяет базовые примитивы: Button, Input, Dialog, Tabs.
  2. Продуктовые компоненты собирают из них сценарий: форма, фильтр, wizard, onboarding, таблица.
  3. React рендерит DOM-дерево.
  4. Браузер и assistive technologies читают уже не ваши абстракции, а итоговую семантику, роли, связи и фокус.

Именно поэтому a11y-ошибка часто не заметна на уровне prop API. Снаружи у компонента все красиво: isOpen, onClose, title. Но если внутри модалки нет ловушки фокуса, отсутствует название диалога и не восстанавливается фокус в trigger, конечный сценарий все равно сломан. Для таких случаев полезно помнить и про React Portals: перенос в другой DOM-узел решает layering, но не решает доступность сам по себе.

Где проходят границы ответственности

  • Дизайн-система отвечает за безопасные примитивы по умолчанию.
  • Продуктовый код отвечает за осмысленные тексты, порядок шагов и реальные сценарии.
  • QA или frontend-разработчик проверяет клавиатуру, screen reader и регрессии.
  • Линтер и автоматические проверки ловят только часть проблем.

Если в проекте нет этого разделения, доступность почти всегда деградирует по мере роста UI. Один разработчик добавил кастомный dropdown, другой переиспользовал его как select, третий обернул в popover, и через пару релизов команда уже не понимает, где должен быть реализован focus management.

Сравнение подходов: нативная семантика, ARIA и готовые примитивы

Ниже полезная калибровка для типичных решений в React-проектах.

КритерийНативный HTMLКастомный div + ARIAГотовые headless/UI primitives
Скорость стартаВысокаяКажется высокой, но быстро растет цена баговСредняя
Базовая доступностьЧасто хорошая сразуНизкая без ручной доработкиОбычно хорошая, если библиотека зрелая
Гибкость дизайнаСредняяВысокаяВысокая
Стоимость поддержкиНизкаяВысокаяСредняя
Риск keyboard-баговНизкийВысокийСредний
Подходит для сложных виджетовОграниченноДа, но дорогоДа, это частый лучший компромисс

Практический вывод простой. Если задача решается нативным элементом, не нужно изобретать новый контрол. Если нужен сложный виджет, команда либо инвестирует в собственный a11y-слой, либо берет зрелые примитивы и адаптирует их под дизайн.

Пример: доступная модалка в React

Модальные окна часто становятся камнем преткновения даже для сильных команд: визуально они работают, а по факту ломают порядок фокуса и закрытие с клавиатуры.

import { useEffect, useId, useRef } from "react";
import { createPortal } from "react-dom";

type DialogProps = {
  title: string;
  isOpen: boolean;
  onClose: () => void;
  children: React.ReactNode;
};

export function Dialog({ title, isOpen, onClose, children }: DialogProps) {
  const titleId = useId();
  const dialogRef = useRef<HTMLDivElement | null>(null);
  const triggerRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (!isOpen) {
      return;
    }

    triggerRef.current = document.activeElement as HTMLElement | null;
    dialogRef.current?.focus();

    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        onClose();
      }
    };

    document.addEventListener("keydown", onKeyDown);
    return () => {
      document.removeEventListener("keydown", onKeyDown);
      triggerRef.current?.focus();
    };
  }, [isOpen, onClose]);

  if (!isOpen) {
    return null;
  }

  return createPortal(
    <div className="backdrop" onClick={onClose}>
      <div
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        tabIndex={-1}
        className="dialog"
        onClick={(event) => event.stopPropagation()}
      >
        <h2 id={titleId}>{title}</h2>
        <button type="button" onClick={onClose} aria-label="Закрыть окно">
          ×
        </button>
        {children}
      </div>
    </div>,
    document.body
  );
}

Это не полный production-ready dialog, потому что здесь нет полноценной ловушки фокуса. Но пример показывает минимальную механику:

  • у диалога есть имя через aria-labelledby;
  • фокус переводится внутрь при открытии;
  • Escape закрывает окно;
  • фокус возвращается в исходный trigger после закрытия.

Если компонент растет дальше, он быстро становится похож на отдельную подсистему. Тогда выгоднее не импровизировать, а строить решение на проверенных примитивах и аккуратно встраивать его в общую архитектуру больших React-приложений.

Прокачай React за 7 дней

20 вопросов и разборов по React Hooks

Начать

Производственные ошибки: где a11y ломается в реальных проектах

Кликабельные карточки на div

Симптомы:

  • карточка реагирует только на мышь;
  • фокус не попадает на нее через Tab;
  • аналитика показывает низкий completion rate на клавиатурных сценариях.

Последствие в production простое: часть пользователей физически не может пройти поток. Исправление тоже простое: превращать действие в button или ссылку, а не эмулировать поведение вручную.

Потеря фокуса после ререндера

Такое часто случается в формах, фильтрах и списках с условным рендерингом. Пользователь печатает, состояние меняется, узел размонтируется, и фокус перемещается в начало страницы. На уровне метрик это часто видно как странные повторные клики, повторный ввод и высокий drop-off на форме.

Выявлять проблему лучше на ранних этапах, особенно если экран уже страдает от лишних обновлений. Здесь будет полезна информация из статьи о том, когда компонент действительно перерисовывается: a11y и границы ререндеров пересекаются намного чаще, чем кажется.

Ошибки формы видны глазами, но не озвучиваются

Красный текст под полем еще не делает форму доступной. Если ошибка не связана с инпутом через aria-describedby, screen reader не даст пользователю той же информации. Команда видит красивый UI, а реальный сценарий submit остается сломанным.

Чрезмерное использование aria-live

Иногда разработчики пытаются озвучивать все подряд: каждую цифру в фильтре, каждое обновление списка, каждую фазу загрузки. В результате screen reader начинает говорить слишком много, а полезный сигнал теряется. aria-live нужен для действительно важных событий, а не для любого изменения состояния.

Производительность и accessibility: где trade-off реальный, а где надуманный

Вокруг a11y часто появляется миф, что доступность обязательно делает приложение более ресурсоёмким. Обычно это не так.

Большинство базовых практик почти ничего не стоят:

  • правильные теги;
  • label;
  • aria-* атрибуты;
  • корректный порядок фокуса;
  • понятные тексты кнопок.

Настоящая цена появляется в другом месте:

  • сложные headless-виджеты с keyboard navigation;
  • focus trap в модалках и меню;
  • виртуализированные списки, которые должны остаться понятными для assistive tech;
  • частые live region updates.

Здесь bottleneck не в самих атрибутах, а в архитектуре состояния и структуре DOM. Если меню из 500 элементов перерисовывается на каждый hover, проблема не в accessibility как таковой. Она в слишком широких обновлениях и плохой декомпозиции. Диагностировать такие случаи лучше вместе с React performance profiling, а не угадывать по ощущениям.

Отдельный спорный случай связан с виртуализацией таблиц и списков. Она полезна для производительности, но может ухудшить навигацию и восприятие контента screen reader-ом, если пользователь видит только маленькое окно DOM и постоянно теряет контекст. Здесь нет универсального ответа. Нужно смотреть на сценарий: admin-панель на 50 тысяч строк и пользовательская таблица заказов требуют разной калибровки.

Best practices для React accessibility

Строить безопасные примитивы, а не чинить каждый экран отдельно

Если Button, Field, Dialog и Tabs из дизайн-системы по умолчанию дают правильную семантику, команда реже ломает доступность случайно. Это архитектурно выгоднее, чем чинить каждый продуктовый экран постфактум.

Проверять поведение, а не только наличие атрибутов

aria-label в коде еще не означает, что сценарий работает. Нужно пройти путь пользователя: открыть, заполнить, закрыть, исправить ошибку, отправить. Только так видно, что фокус не потерян и сообщения действительно озвучиваются.

Держать a11y-проверки в CI и локальной разработке

Практичный минимум:

  • eslint-plugin-jsx-a11y для явных анти-паттернов;
  • автоматические проверки через axe;
  • ручной keyboard pass на ключевых страницах;
  • smoke-проверка со screen reader для форм, навигации и модалок.

Не путать дизайн с семантикой

Компонент может выглядеть как кнопка, но быть span. Может выглядеть как табы, но не иметь ролей и состояния. Может выглядеть как ошибка, но быть недоступным для чтения. Это одна из причин, почему a11y стоит обсуждать на уровне API компонентов, а не только на уровне пикселей.

Частые ошибки

  • Используют div и span там, где нужны button, a, label, input.
  • Убирают outline и не добавляют заметный focus style взамен.
  • Пишут placeholder вместо нормального label.
  • Добавляют много ARIA-атрибутов, не понимая, зачем каждый нужен.
  • Делают модалку без возврата фокуса и без закрытия по Escape.
  • Рендерят кастомный select, который не работает с клавиатуры.
  • Считают, что покупка UI-библиотеки автоматически закрывает тему accessibility.

Как отвечать на интервью

Сильный ответ на тему React accessibility обычно не сводится к перечислению терминов. Интервьюер хочет понять, видите ли вы доступность как часть инженерной модели интерфейса.

Рабочая структура ответа такая:

  1. React сам не делает интерфейс доступным, он только рендерит ваше дерево.
  2. Базовый уровень начинается с semantic HTML, корректных форм и клавиатурной навигации.
  3. ARIA применяют там, где нативной семантики недостаточно, а не вместо нее.
  4. В сложных виджетах критичны focus management, accessible name и предсказуемые состояния.
  5. Проверка должна включать не только линтер, но и реальный сценарий с клавиатурой и screen reader.

Если вы хотите показать уровень middle, добавьте конкретный кейс. Например: как вы проектировали доступную модалку, почему не делали кнопку через div, как связывали error text с полем или почему осторожно используете aria-live.

Прокачай React до уровня сильного технического интервью

Разбираем реальные frontend-вопросы по React: архитектура компонентов, формы, performance, accessibility и аргументация решений без заученных шаблонов

Начать подготовку

FAQ

Нужно ли добавлять ARIA почти в каждый React-компонент?

Нет. Если интерфейс можно выразить нативным HTML, это почти всегда лучший путь. ARIA нужен как слой уточнения, а не как костыль для плохой семантики.

Можно ли сделать доступный интерфейс только с помощью UI-библиотеки?

Не полностью. Библиотека может помочь с базовыми примитивами, но она не знает ваши реальные тексты, шаги сценария, правила валидации и порядок переходов между экранами.

Что обычно спрашивают про accessibility на React-собеседовании?

Чаще всего проверяют базу: semantic HTML, клавиатуру, aria-label и aria-labelledby, доступные формы, модалки, табы и типичные anti-patterns вроде кликабельных div.

React сам делает приложение доступным?

Нет. Если разработчик выбрал плохие теги и не продумал фокус, React это не исправит. Доступность определяется итоговым DOM и поведением интерфейса.

Как проверить базовую доступность без отдельной команды QA?

Комбинированный подход: линтер, автоматические проверки, ручной проход клавиатурой и хотя бы базовая проверка ключевых экранов со screen reader. Только так видны реальные поведенческие дефекты.

Итоги

React accessibility начинается не с ARIA, а с дисциплины в компонентах. Если команда сохраняет семантику, не ломает клавиатурную навигацию, аккуратно управляет фокусом и проверяет реальные сценарии, базовый уровень a11y достигается довольно быстро.

Сложность начинается там, где UI отрывается от нативного поведения: кастомные виджеты, модалки, селекты, виртуализированные списки, сложные формы. В этих местах доступность уже становится архитектурной задачей. И именно там видно, насколько зрелой является frontend-команда.

Больше вопросов в Telegram

Ежедневные разборы и реальные кейсы с интервью

Подписаться

Автор

Lexicon Team

Читайте также