Создание доступных модальных окон: нативный `<dialog>` или библиотека?
Нативный <dialog> или библиотека для доступных модалок: фокус, inert-фон, ESC, блокировка прокрутки и когда нужны кастомные focus trap.
В 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() дают бесплатно — и чего не дают
Discover how at OpenReplay.com.
Вызов showModal() на элементе <dialog> обеспечивает без какого-либо дополнительного JavaScript: перемещение начального фокуса внутрь диалога, обработку закрытия по клавише Esc браузером, рендеринг в верхнем слое, исключающий проблемы с z-index и порталами, фон, стилизуемый через ::backdrop, неявный role="dialog" с aria-modal="true", а также inert-фон, не позволяющий Tab достигать содержимого страницы позади диалога. close() затем возвращает фокус на элемент, открывший диалог. Джаред Кунья, перестроивший модальное окно U.S. Web Design System на основе <dialog>, сообщает, что переработка сократила код примерно с 400 строк JavaScript плюс четыре утилиты до около 38 строк — поведение для обеспечения доступности, которое раньше приходилось прописывать вручную, теперь является браузерным поведением по умолчанию.
Чего он не даёт и как это исправить:
| Проблема | Что происходит | Решение |
|---|---|---|
| Первый интерактивный элемент в конце DOM | showModal() фокусируется на нём, и диалог открывается прокрученным вниз | Поместите кнопку закрытия после заголовка в порядке источника; или явно задайте начальный фокус |
Атрибут 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; известные ограничения на мобильных AT | 8.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(), где браузер уже удерживает фокус на странице.
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