lenec ru

← все посты

Доступные модальные окна: focus trap и возврат фокуса без библиотек

14K

Из всех виджетов в интерфейсе модальное окно — самый частый источник проблем с доступностью. На внешний вид всё кажется простым: затемнение, белый прямоугольник, кнопка «Закрыть». Внутри — куча тонкостей, про которые нигде не написано в одном месте: focus trap, возврат фокуса, блокировка фона, корректная роль, обработка Escape, скроллинг.

Я подняла десятки модалок в дизайн-системе, прежде чем перестала пользоваться сторонними библиотеками. Реализация на чистом React+TypeScript занимает 80 строк. И ты понимаешь, что и зачем там происходит. Эта статья — пошаговый разбор такой реализации.

Что должна делать доступная модалка

  1. При открытии — переместить фокус внутрь модалки.
  2. Удержать Tab/Shift+Tab внутри модалки (focus trap).
  3. Закрываться по Escape.
  4. При закрытии — вернуть фокус на элемент, который её открыл.
  5. Иметь корректные ARIA-атрибуты: role="dialog", aria-modal="true", aria-labelledby.
  6. Блокировать взаимодействие с фоном (включая скринридер).
  7. Не позволять скроллу страницы под модалкой.

Все семь пунктов обязательны. Пропущенный любой из них — баг доступности.

Каркас компонента

Начнём с минимального скелета:

import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import "./Modal.css";

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

export function Modal({ isOpen, onClose, title, children }: Props) {
  const dialogRef = useRef<HTMLDivElement>(null);

  if (!isOpen) return null;

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

Что тут уже сделано:

  • Портал в body — модалка не привязана к DOM-родителю и не страдает от overflow: hidden и z-index родителей.
  • role="dialog" + aria-modal="true" — скринридер понимает, что это модальное окно.
  • aria-labelledby связывает заголовок с самим dialog. Скринридер озвучит «Заголовок диалог».
  • Закрытие по клику на backdrop, но не на саму модалку (stopPropagation).

Перенос фокуса при открытии

Когда модалка появилась, скринридер должен начать читать её заголовок. Для этого фокус должен переехать внутрь.

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

  const dialog = dialogRef.current;
  if (!dialog) return;

  // фокус на сам диалог (если на нём есть tabindex="-1")
  // либо на первый интерактивный элемент внутри
  const firstFocusable = dialog.querySelector<HTMLElement>(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  firstFocusable?.focus();
}, [isOpen]);

Иногда правильнее ставить фокус на сам диалог (через tabindex="-1"), а не на первую кнопку. Особенно если модалка просто информационная и никаких форм нет. Тогда скринридер с фокуса диалога озвучит весь его контент.

Возврат фокуса при закрытии

Самый забываемый пункт. Модалка закрылась — а фокус остался на «исчезнувшей» кнопке закрытия. Пользователь клавиатуры теряет место, на котором был.

Решение — запоминать активный элемент перед открытием и возвращать его при закрытии:

const previouslyFocused = useRef<HTMLElement | null>(null);

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

  previouslyFocused.current = document.activeElement as HTMLElement;

  return () => {
    previouslyFocused.current?.focus();
  };
}, [isOpen]);

На клик «Открыть» сохраняется текущий focused element. На размонтаж модалки (когда isOpen становится false) — фокус возвращается. Без этого пользователь оказывается на body и теряет контекст.

Focus trap: Tab внутри модалки

Если в модалке есть несколько интерактивных элементов, Tab должен ходить между ними кругами. По умолчанию Tab выйдет из модалки в фон, что для пользователя клавиатуры — катастрофа.

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

  const dialog = dialogRef.current;
  if (!dialog) return;

  function handleKeyDown(e: KeyboardEvent) {
    if (e.key !== "Tab") return;

    const focusable = Array.from(
      dialog!.querySelectorAll<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      )
    ).filter((el) => !el.hasAttribute("disabled"));

    if (focusable.length === 0) return;

    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  }

  dialog.addEventListener("keydown", handleKeyDown);
  return () => dialog.removeEventListener("keydown", handleKeyDown);
}, [isOpen]);

Логика: при Tab с последнего элемента — переходим на первый. При Shift+Tab с первого — на последний. Внутри модалки Tab крутится кругами.

В современных браузерах есть атрибут inert и встроенный focus management у <dialog> с showModal(). Если нативный диалог тебе подходит, можно вообще не писать ручной trap — браузер сделает сам. Но кастомный диалог часто нужен ради дизайна, и тогда trap пишется руками.

Закрытие по Escape

Стандартное поведение модальных окон. Пользователь нажимает Escape — окно закрывается.

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

  function handleKeyDown(e: KeyboardEvent) {
    if (e.key === "Escape") {
      onClose();
    }
  }

  document.addEventListener("keydown", handleKeyDown);
  return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);

Слушаем на document, потому что фокус может быть где угодно внутри модалки.

Блокировка скролла фона

Когда модалка открыта, страница за ней не должна скроллиться. У пользователя мыши при колесе скролл «утекает» в фон, что выглядит как баг.

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

  const original = document.body.style.overflow;
  document.body.style.overflow = "hidden";

  return () => {
    document.body.style.overflow = original;
  };
}, [isOpen]);

Простой способ. На iOS Safari он не идеален — там нужен дополнительный position: fixed на body. Но для большинства проектов overflow:hidden достаточно.

Изоляция от скринридера: inert

Когда модалка открыта, остальная страница должна быть невидима для скринридера. Иначе пользователь Tab'ом выйдет из модалки и заблудится в фоне.

Современный способ — атрибут inert на остальную часть DOM:

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

  const root = document.getElementById("root");
  if (!root) return;

  root.setAttribute("inert", "");
  root.setAttribute("aria-hidden", "true");

  return () => {
    root.removeAttribute("inert");
    root.removeAttribute("aria-hidden");
  };
}, [isOpen]);

Здесь #root — корень основного приложения. Модалка отрендерена через portal в body, поэтому она не дочерняя для #root и под inert не попадает. Inert делает три вещи: убирает элементы из tab-порядка, отключает события, скрывает от скринридера.

Старый способ — aria-hidden="true" + tabindex="-1" на каждом интерактивном элементе фона. Сейчас это излишне: inert поддерживается во всех современных браузерах с 2023.

Полный пример

Собранный воедино компонент:

import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import "./Modal.css";

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

export function Modal({ isOpen, onClose, title, children }: Props) {
  const dialogRef = useRef<HTMLDivElement>(null);
  const previouslyFocused = useRef<HTMLElement | null>(null);

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

    const dialog = dialogRef.current;
    if (!dialog) return;

    previouslyFocused.current = document.activeElement as HTMLElement;

    const firstFocusable = dialog.querySelector<HTMLElement>(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    firstFocusable?.focus();

    const root = document.getElementById("root");
    root?.setAttribute("inert", "");
    const originalOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";

    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === "Escape") {
        onClose();
        return;
      }
      if (e.key !== "Tab") return;

      const focusable = Array.from(
        dialog!.querySelectorAll<HTMLElement>(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        )
      ).filter((el) => !el.hasAttribute("disabled"));
      if (focusable.length === 0) return;

      const first = focusable[0];
      const last = focusable[focusable.length - 1];
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }

    document.addEventListener("keydown", handleKeyDown);

    return () => {
      document.removeEventListener("keydown", handleKeyDown);
      root?.removeAttribute("inert");
      document.body.style.overflow = originalOverflow;
      previouslyFocused.current?.focus();
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

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

Что я тут не делала

Несколько вещей, которые встречаются в продакшн-модалках, но требуют отдельной заметки:

  • Focus visible на dialog. Если ставлю tabindex="-1" и фокусирую сам dialog, рамка фокуса не должна быть видимой — это шум. :focus:not(:focus-visible) { outline: none }.
  • Анимации появления/исчезновения. Это уже про CSS. Главное — учитывать prefers-reduced-motion, я писала об этом отдельно.
  • Несколько вложенных модалок. На самом деле нежелательный UX. Если очень нужно — поддерживай стек открытых модалок, и Escape закрывает только верхнюю.
  • Кастомные дизайны и порталы. Если нужна модалка не в body, а в специфичном контейнере — параметризуй portal target. Главное, чтобы родительский контейнер не имел aria-hidden на момент открытия.

Что унести с собой

Доступная модалка — это не библиотека. Это семь конкретных пунктов, которые нужно реализовать своими руками или проверить, что библиотека их закрывает. Если ты пишешь свою — 80 строк кода, как в примере выше.

Если используешь библиотеку (radix-ui, headlessui, downshift) — открой документацию и найди разделы про focus management, inert, return focus, escape close. Если хотя бы одного из этих пунктов нет в API — библиотека не закрывает доступность, и баги придётся ловить руками. Это нормальный критерий выбора.

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

  • ivblz

    67

    • max

      да что ты

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