12k
All articles

Создание доступных модальных окон: нативный `<dialog>` или библиотека?

Нативный <dialog> или библиотека для доступных модалок: фокус, inert-фон, ESC, блокировка прокрутки и когда нужны кастомные focus trap.

OpenReplay Team
OpenReplay Team
Создание доступных модальных окон: нативный `<dialog>` или библиотека?

В 2026 году используйте нативный элемент <dialog> с showModal() по умолчанию: он обеспечивает удержание фокуса, закрытие по ESC, рендеринг в верхнем слое, блокировку прокрутки фона и неявный aria-modal без единой строки JavaScript. Обращайтесь к библиотекам — Radix, React Aria, Headless UI или a11y-dialog — только тогда, когда вы столкнулись со сложным наложением слоёв, требованиями к сложной анимации, необходимостью компонуемости в рамках дизайн-системы или конкретным ограничением нативного элемента, которое вы реально измерили.

Этот вывод переворачивает многолетние устоявшиеся представления. Стандартный совет — использовать портал для модального окна, вручную удерживать фокус, скрывать фон с помощью aria-hidden, вручную обрабатывать ESC — описывал то, что приходилось делать до появления <dialog> и inert. Элемент <dialog> входит в Baseline для всех основных браузеров с марта 2022 года, поэтому большая часть этих конструкций теперь является браузерным поведением по умолчанию. В этой статье мы разберём выбор между нативным решением и библиотекой с конкретными критериями, правильно объясним нюансы удержания фокуса там, где большинство руководств повторяют устаревшие советы, предоставим проверенную минимальную реализацию на vanilla JS и React, а в конце рассмотрим реальное поведение скринридеров — потому что корректный ARIA сам по себе не решает всех проблем.

Ключевые выводы

  • Нативный <dialog>, открытый с помощью showModal(), помещает диалог в верхний слой браузера и делает остальную часть страницы inert, поэтому фокус удерживается внутри модального окна без какого-либо JavaScript-трапа фокуса.
  • Не добавляйте JavaScript-трап фокуса к нативному <dialog>: единственное, чего Tab всё ещё может достичь — это собственный интерфейс браузера, и пользователи клавиатуры должны иметь к нему доступ точно так же, как пользователи мыши.
  • Ручные трапы фокуса предназначены для кастомных модальных окон, не основанных на <dialog> — и если вы создаёте такое окно, вы сами отвечаете за атрибут inert на фоне, поскольку никто не управляет им за вас.
  • Самая распространённая нативная проблема незаметна: если первый интерактивный элемент находится в конце DOM диалога, showModal() фокусируется на нём, и модальное окно открывается прокрученным вниз — исправьте это, разместив кнопку закрытия после заголовка в исходном порядке.
  • Корректный ARIA необходим, но недостаточен: по состоянию на 2026 год Safari в сочетании с VoiceOver всё ещё может делать статичное содержимое внутри диалога с aria-modal недоступным, поэтому тестируйте на реальных скринридерах.

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

Доступное модальное окно должно обеспечивать пять обязательных поведений: перемещать фокус внутрь себя при открытии, делать фон недоступным и неинтерактивным, допускать закрытие с клавиатуры (ESC) и через видимый элемент управления, возвращать фокус на вызывающий элемент при закрытии, а также предоставлять роль диалога с доступным именем, чтобы скринридеры его объявляли. Это напрямую соответствует критериям успеха WCAG — порядок фокуса (2.4.3), управление с клавиатуры (2.1.1, 2.1.2) и имя/роль/значение (4.1.2). Полный краткий справочник WCAG 2.2 содержит остальные требования; этот короткий набор определяет, работает ли модальное окно или нет.

Причина частых ошибок в том, что каждое из этих поведений — отдельный механизм в модальном окне, реализованном вручную через position: fixed. Вы самостоятельно прописываете перемещение фокуса, его возврат, обработчик ESC, скрытие фона и настройку роли — и если хотя бы один из них незаметно сломается, модальное окно будет выглядеть нормально для пользователя мыши, но окажется сломанным для всех остальных. Аргумент в пользу нативного элемента состоит в том, что он сводит большую часть этих механизмов к одному вызову DOM.

Что <dialog> и showModal() дают бесплатно — и чего не дают

Вызов showModal() на элементе <dialog> обеспечивает без какого-либо дополнительного JavaScript: перемещение начального фокуса внутрь диалога, обработку закрытия по клавише Esc браузером, рендеринг в верхнем слое, исключающий проблемы с z-index и порталами, фон, стилизуемый через ::backdrop, неявный role="dialog" с aria-modal="true", а также inert-фон, не позволяющий Tab достигать содержимого страницы позади диалога. close() затем возвращает фокус на элемент, открывший диалог. Джаред Кунья, перестроивший модальное окно U.S. Web Design System на основе <dialog>, сообщает, что переработка сократила код примерно с 400 строк JavaScript плюс четыре утилиты до около 38 строк — поведение для обеспечения доступности, которое раньше приходилось прописывать вручную, теперь является браузерным поведением по умолчанию.

Чего он не даёт и как это исправить:

ПроблемаЧто происходитРешение
Первый интерактивный элемент в конце DOMshowModal() фокусируется на нём, и диалог открывается прокрученным внизПоместите кнопку закрытия после заголовка в порядке источника; или явно задайте начальный фокус
Атрибут autofocusНенадёжен для перемещения начального фокуса в некоторых десктопных браузерахУстанавливайте фокус в JS после showModal(), нацеливаясь на нужный элемент
Клик по фонуНативный диалог не закрывается по клику снаружи по умолчаниюОтслеживайте клики, при которых event.target === dialogEl
Прокрутка фонаСтраница позади диалога всё ещё может прокручиватьсяЯвно блокируйте прокрутку (см. ниже)

Поведение с прокруткой вниз — не особенность одной библиотеки: пример модального диалога в W3C ARIA APG документирует, что размещение начального фокуса на элементе в конце содержимого диалога может привести к тому, что диалог откроется прокрученным за пределы видимости. Самое чистое решение — порядок в источнике: сначала заголовок, затем кнопка закрытия, чтобы скринридер объявил заголовок перед элементом управления, а диалог открылся сверху.

Для блокировки прокрутки зафиксируйте позицию прокрутки, заблокируйте body и восстановите при закрытии:

:root:has(dialog[open]) {
  overflow: hidden;
  scrollbar-gutter: stable; /* предотвращает сдвиг макета при исчезновении полосы прокрутки */
}

scrollbar-gutter: stable резервирует ширину полосы прокрутки, чтобы страница не смещалась горизонтально при блокировке прокрутки. В iOS Safari overflow: hidden на body печально известен своей ненадёжностью; захват window.scrollY и применение position: fixed с отрицательным top — более надёжный кросс-платформенный подход, когда он необходим.

Вопрос об удержании фокуса, решённый окончательно: не удерживайте фокус в нативном диалоге

Не добавляйте JavaScript-трап фокуса к нативному <dialog>, открытому с помощью showModal(). Браузер уже помещает его в верхний слой и делает остальную часть страницы inert, поэтому фокус удерживается — единственное, чего Tab всё ещё может достичь, — это собственный интерфейс браузера (адресная строка, вкладки, расширения), и пользователи клавиатуры должны иметь к нему доступ точно так же, как пользователи мыши. Это самый часто повторяемый устаревший совет в руководствах по модальным окнам, и он неверен применительно к нативному элементу.

Это обоснование исходит от авторитетных специалистов по доступности и обобщено Зеллом Льювом в статье CSS-Tricks “There is No Need to Trap Focus on a Dialog Element”. Позиция Скотта О’Хары состоит в том, что WCAG нормативно не требует удержания фокуса; рекомендация по трапу появилась в информативном руководстве, написанном до широкого распространения inert и <dialog>, когда в скриптовых диалогах удержание фокуса было проще, чем исключение внешних элементов из порядка табуляции. Формулировка Леони Уотсон такова: пользователи должны иметь те же возможности управления внутри диалога, что и на обычной странице — они по-прежнему должны иметь доступ к элементам управления браузером. Позиция рабочей группы W3C APA состоит в том, что нативное поведение диалога должно оставаться неизменным, отчасти потому, что переход к интерфейсу браузера является механизмом выхода в киоск-режиме и в ограниченных средах.

Это реальная, актуальная дискуссия, а не устоявшееся правило. Джаред Кунья в упомянутой работе по USWDS всё же добавляет функцию удержания фокуса к нативному элементу. Его обоснование — практическая осторожность. Однако нормативная интерпретация склоняется к позиции «без трапа» для нативного элемента, а добавление трапа активно лишает пользователей клавиатуры возможности, которая есть у них по умолчанию.

Оговорка, которая завершает правило: ручные трапы фокуса предназначены для кастомных модальных окон, не основанных на <dialog> — и если вы создаёте такое окно, вы сами отвечаете за inert на фоне, поскольку никто не управляет им за вас. Совет «всегда удерживайте фокус» не является неверным в целом — он неверен, когда применяется к элементу, который уже обеспечивает удержание.

Минимальная корректная реализация нативного <dialog>

Ниже представлена версия на vanilla JS с тремя улучшениями, которые стоит добавить: закрытие по клику на фон, анимация с учётом reduced-motion и ничего лишнего. Возврат фокуса уже обеспечивается методом close().

<button id="open">Открыть диалог</button>

<dialog id="dlg" aria-labelledby="dlg-title">
  <h2 id="dlg-title">Подтвердить изменения</h2>
  <p>Ваши правки будут применены немедленно.</p>
  <form method="dialog">
    <button value="cancel">Отмена</button>
    <button value="confirm">Подтвердить</button>
  </form>
</dialog>
const dlg = document.getElementById('dlg');

document.getElementById('open').addEventListener('click', () => dlg.showModal());

// Клик по фону закрывает диалог (нативный элемент этого НЕ делает).
dlg.addEventListener('click', (e) => {
  if (e.target === dlg) dlg.close();
});

aria-labelledby указывает на видимый заголовок, задавая диалогу доступное имя. <form method="dialog"> закрывает диалог при отправке и записывает нажатую кнопку в dlg.returnValue — без дополнительного JS. Проверка фона работает потому, что клик по ::backdrop регистрируется как клик на элементе <dialog>, тогда как клик по содержимому регистрируется на дочернем элементе.

Анимируйте диалог без конфликтов с верхним слоем и с учётом настроек движения:

dialog {
  opacity: 0;
  transition: opacity 0.2s, overlay 0.2s allow-discrete, display 0.2s allow-discrete;
}
dialog[open] { opacity: 1; }
@starting-style {
  dialog[open] { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  dialog { transition: none; }
}

@starting-style и transition-behavior: allow-discrete обеспечивают работу анимации входа для элемента, перемещающегося в верхний слой и из него. Блок prefers-reduced-motion соответствует требованию WCAG 2.3.3.

Версия на React использует useRef для вызова императивных методов и useId для безопасной в контексте SSR привязки метки:

import { useRef, useEffect, useId } from 'react';

function Dialog({ open, onClose, title, children }) {
  const ref = useRef(null);
  const titleId = useId();

  useEffect(() => {
    const el = ref.current;
    if (open) el.showModal();
    else el.close();
  }, [open]);

  return (
    <dialog
      ref={ref}
      aria-labelledby={titleId}
      onClose={onClose}
      onClick={(e) => { if (e.target === ref.current) ref.current.close(); }}
    >
      <h2 id={titleId}>{title}</h2>
      {children}
    </dialog>
  );
}

Нативное событие close (генерируемое при нажатии ESC, обработчике фона или отправке формы) управляет вашим onClose, синхронизируя состояние React с элементом. Используйте role="alertdialog" вместо значения по умолчанию для деструктивных подтверждений и не закрывайте такие диалоги по клику на фон.

Нативный <dialog> vs библиотека: матрица принятия решений 2026 года

Используйте эту таблицу для выбора. Проверяйте версии на npm при интеграции, поскольку патч-версии могут меняться.

ВариантКогда выигрываетКомпромиссТекущая версия
Нативный <dialog>Большинство модальных окон; нужны нулевые JS-зависимости и встроенная доступностьМеньше контроля над анимацией и наложением; закрытие по фону настраивается вручнуюBaseline с марта 2022
a11y-dialog (vanilla)Vanilla/не-React проекты, которым нужна проверенная обёртка и событияЗависит от aria-modal; известные ограничения на мобильных AT8.1.5
Radix / shadcn (React)Команды с дизайн-системой; компонуемые примитивы; рабочий процесс shadcnДобавляет слой примитивов и увеличивает бандлunified radix-ui 1.6.0
React Aria (React)Приложения с критичными требованиями к доступности, которым нужны гарантии поведения от AdobeБолее сложный API; самоуверенный подход@react-aria/dialog 3.5.34
Headless UI (React)Tailwind-проекты, которым нужны несвязанные со стилями примитивыТолько React; кастомная реализация (не на <dialog>)2.2.10
Base UI (React)Новые проекты, которым нужны современные примитивы от команды MUIБолее молодая экосистемастабильная v1.0 (декабрь 2025)

Последняя версия a11y-dialog — 8.1.5, и согласно его собственной документации по известным проблемам, он опирается на aria-modal (WAI-ARIA 1.1), поддержка которого недостаточно хороша в некоторых мобильных вспомогательных технологиях — использование хелпера aria-hidden в паре с ним является задокументированным способом решения проблемы.

Headless UI для React находится на версии 2.2.10, опубликованной 7 апреля 2026 года. Согласно документации Headless UI, его Dialog — это кастомный компонент на основе div, который перемещает фокус внутрь и удерживает его — что корректно, поскольку это не нативный <dialog>, а значит, удержание фокуса необходимо. Согласно документации React Aria, фокус перемещается в диалог при монтировании и возвращается на триггер при размонтировании, оставаясь удержанным в процессе. Одно предостережение: документация React Aria всё ещё описывает HTML-элемент <dialog> как недостаточно широко поддерживаемый — это утверждение устарело с учётом статуса Baseline с 2022 года, поэтому не наследуйте его.

Ландшафт headless-решений действительно изменился в конце 2025 — начале 2026 года. Base UI, поддерживаемый командой MUI и созданный выходцами из Radix, Floating UI и Material UI, достиг стабильной версии v1.0 в декабре 2025 года, а shadcn/ui консолидировал использование Radix в унифицированном пакете radix-ui в феврале 2026 года. Один часто путаемый факт: родительская компания Radix — Modulz — была приобретена WorkOS ещё в 2022 году — это четырёхлетняя история, а не свежие новости. Реальное недавнее изменение — появление Base UI как альтернативного слоя примитивов. Большинство команд по-прежнему используют Radix через shadcn/ui.

Когда нельзя использовать <dialog>: чеклист для кастомного модального окна

Если вам необходимо создать кастомное модальное окно — немодальные сосуществующие диалоги, нестандартное наложение слоёв или устаревшие ограничения — вы берёте на себя ответственность за всё, что обеспечивал нативный элемент. Как минимум:

  • перемещайте фокус внутрь при открытии и возвращайте его при закрытии;
  • применяйте атрибут inert к остальной части приложения (более надёжно, чем aria-hidden, поскольку inert также исключает элементы из порядка табуляции);
  • реализуйте настоящий трап фокуса, циклически перебирающий Tab и Shift+Tab по интерактивным дочерним элементам;
  • добавьте обработчик нажатия клавиши ESC;
  • блокируйте прокрутку фона; и
  • установите role="dialog"/alertdialog с aria-modal="true" и доступным именем.

Это тот случай — и единственный случай — когда совет «всегда удерживайте фокус» является правильным.

Именно такие баги доступности кастомных модальных окон выявляет запись сессий в продакшне, поскольку она восстанавливает реальный путь фокуса и клавиатурный след пользователя. Вы можете наблюдать, как пользователь клавиатуры переходит по Tab прямо из кастомного модального окна в фоновое содержимое — безошибочный признак отсутствующего inert или сломанного трапа. Вы видите, как фокус оказывается нигде после закрытия модального окна — признак того, что ссылка на открывающий элемент никогда не была сохранена. Вы видите модальное окно, которое вообще нельзя закрыть с клавиатуры, потому что кастомная реализация была выпущена без обработчика ESC. Это те режимы отказа, которые проходят аудит, но выявляются при записи сессий.

Реальность скринридеров: корректный ARIA недостаточен

Корректный ARIA необходим, но недостаточен. По состоянию на 2026 год Safari в сочетании с VoiceOver всё ещё может делать статичное, нефокусируемое содержимое внутри диалога с aria-modal недоступным для виртуального курсора — известная проблема WebKit, подтверждённая материалом Deque от декабря 2025 года о модальных диалогах ARIA. Практическое следствие: идеально размеченный диалог всё ещё может скрывать основной текст от пользователя VoiceOver. Проверяйте это; не принимайте как данность.

Кроме того, NVDA может не объявлять роль диалога в каждой конфигурации, а TalkBack может пропускать абсолютно позиционированное содержимое. Вывод касается процесса, а не разметки: тестируйте как минимум на двух комбинациях скринридер/браузер — VoiceOver/Safari и NVDA/Firefox или Chrome как минимум — потому что автоматизированный аудит подтверждает наличие атрибутов, но не то, что реальная вспомогательная технология достигает содержимого.

Заключение

Начинайте с нативного <dialog>, добавьте четыре небольших улучшения (закрытие по фону, явный начальный фокус, блокировка прокрутки, анимация с учётом reduced-motion) и не поддавайтесь желанию добавить трап фокуса, который здесь не нужен. Подключайте Radix, React Aria, Headless UI, a11y-dialog или Base UI только тогда, когда конкретное требование — компонуемость, анимация, наложение слоёв — перевешивает преимущества элемента без зависимостей, который обеспечивает доступность за вас. Затем откройте скринридер и убедитесь в том, что аудит не может проверить: что реальный пользователь действительно может добраться до каждого слова.

Часто задаваемые вопросы

Работает ли нативный элемент dialog без JavaScript с использованием атрибута form method dialog?

Диалог может открываться без JavaScript только при использовании атрибута open, но это отображает его как немодальный диалог без верхнего слоя, без inert-фона и без удержания фокуса. Модальное поведение требует вызова showModal() в JavaScript, который нельзя запустить декларативно. Однако закрыть диалог без дополнительного JavaScript можно, разместив внутри него форму с method='dialog'; отправка этой формы закрывает диалог и записывает нажатую кнопку в свойство returnValue.

В чём разница между role dialog и role alertdialog в модальном окне?

Стандартный role='dialog' нативного элемента dialog предназначен для общих модальных окон, в которых пользователи просматривают содержимое или вводят данные, тогда как role='alertdialog' сигнализирует о срочном, прерывающем работу сообщении, требующем ответа, — например, подтверждении деструктивного действия. Скринридеры объявляют содержимое alertdialog более настойчиво. Для модальных окон с alertdialog не следует разрешать закрытие по клику на фон или случайное закрытие, поскольку этот паттерн предполагает, что пользователь должен сделать осознанный выбор, а не случайно закрыть диалог.

Почему мой диалог открывается прокрученным вниз, а не вверх?

Это происходит, когда первый интерактивный элемент в DOM диалога находится в конце содержимого. При вызове showModal() браузер перемещает фокус на этот первый интерактивный элемент, и если он находится внизу, диалог открывается прокрученным к нему. Руководство W3C ARIA Authoring Practices Guide документирует это же поведение. Исправьте это, упорядочив источник так, чтобы заголовок шёл перед первым интерактивным элементом, — например, разместив кнопку закрытия после заголовка, а не перед ним.

Можно ли стилизовать открытый диалог с помощью псевдокласса :open вместо селектора атрибута open?

Псевдокласс :open может стилизовать открытый диалог, однако он стал Baseline newly available только в мае 2026 года, когда его поддержку добавил Safari 26.5. Поскольку он является newly available, а не широко поддерживаемым в старых версиях браузеров, более безопасным кросс-браузерным селектором остаётся селектор атрибута dialog[open], который работает с момента достижения элементом статуса Baseline в марте 2022 года. Используйте селектор атрибута как основной подход и рассматривайте :open как прогрессивное улучшение до широкого распространения поддержки.

Использует ли Headless UI нативный элемент dialog, и нужно ли всё равно удерживать фокус?

Компонент Dialog в Headless UI — это кастомная реализация на основе div, а не нативный HTML-элемент dialog, поэтому он корректно перемещает фокус внутрь и удерживает его при переходе пользователя между интерактивными элементами. Это соответствует правилу: кастомные модальные окна, не основанные на dialog, должны удерживать фокус и самостоятельно управлять inert-состоянием фона. Не следует убирать его трап фокуса. Рекомендация «без трапа фокуса» применима только к нативному элементу dialog, открытому с помощью showModal(), где браузер уже удерживает фокус на странице.

Digital experience platform

Truly understand users experience

See every user interaction, feel every frustration and track all hesitations with OpenReplay — the open-source digital experience platform. It can be self-hosted in minutes, giving you complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.