Ловушка фокуса в модальных окнах
Модалка, из которой Tab уводит на страницу под ней, — не модалка, а декорация. Разбираем контракт по-настоящему модального диалога.
Ловушка фокуса (focus trap) — намеренное удержание фокуса внутри открытого диалога: Tab с последнего элемента возвращает на первый, Shift+Tab с первого — на последний. Это единственный случай, когда «запереть» пользователя не только можно, но и нужно.
Зачем это нужно на практике
Возьмём обычную модалку «Удалить проект?» с кнопками «Отмена» и «Удалить». Для зрячего пользователя мыши всё честно: фон затемнён, окно поверх, кликать по фону бессмысленно. Теперь то же окно глазами клавиатуры.
Пользователь нажал Enter на кнопке «Удалить проект». Модалка появилась — но фокус остался на кнопке под затемнением. Он жмёт Tab: фокус идёт дальше по странице за модалкой. Кольца не видно, потому что оно под полупрозрачной подложкой. Ещё Tab — и он в шапке сайта, потом в футере, потом уходит в адресную строку браузера. Модалка на экране висит. Что в ней написано — неизвестно: скринридер до неё не добрался, он читает страницу, которая для всех остальных уже недоступна.
Итог: диалог, который для мыши блокирующий, а для клавиатуры и скринридера — просто ещё один блок где-то в разметке. Человек не может ни прочитать вопрос, ни ответить на него, ни закрыть окно. Ровно поэтому у модалки есть жёсткий контракт поведения — и он одинаковый для всех библиотек, фреймворков и самописных решений.
Контракт доступного диалога
| Событие | Что обязано произойти |
| Открытие | Фокус переезжает внутрь диалога: на первый интерактивный элемент, или на заголовок с tabindex="-1", или на сам контейнер |
| Tab на последнем элементе | Фокус переходит на первый элемент диалога, а не на страницу под ним |
| Shift+Tab на первом элементе | Фокус переходит на последний элемент диалога |
| Escape | Диалог закрывается |
| Клик по подложке | Закрывает — если диалог необязательный (для деструктивных подтверждений часто отключают) |
| Закрытие | Фокус возвращается на элемент, который открыл диалог |
| Фон, пока диалог открыт | Недоступен ни мыши, ни клавиатуре, ни скринридеру |
| Разметка | Роль dialog, aria-modal="true" и доступное имя (aria-labelledby) |
Обратите внимание на последний пункт про Escape. Ловушка без выхода — это уже настоящая ловушка: пользователь заперт и не может ничего сделать. Escape обязателен всегда, даже в «важных» диалогах: закрыть окно и не выполнить действие — не то же самое, что подтвердить его.
Разметка
<button type="button" id="delete-btn">Удалить проект</button>
<div class="overlay" hidden>
<div class="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="dlg-title"
aria-describedby="dlg-desc">
<h2 id="dlg-title" tabindex="-1">Удалить проект?</h2>
<p id="dlg-desc">Проект и все его задачи будут удалены безвозвратно.</p>
<button type="button" data-close>Отмена</button>
<button type="button" data-confirm>Удалить</button>
</div>
</div>
Что здесь важно:
role="dialog"— скринридер объявит «диалог».aria-modal="true"— подсказка скринридеру, что содержимое вне диалога сейчас не существует. Это подсказка, а не механизм: сам по себе атрибут не мешает Tab уйти на фон.aria-labelledby— имя диалога. Без него скринридер скажет просто «диалог» и замолчит.tabindex="-1"на заголовке — чтобы можно было поставить фокус именно туда: тогда при открытии человек услышит вопрос целиком, а не сразу «кнопка Отмена».
Нативный <dialog> — почти всё бесплатно
Если можете позволить себе нативный элемент, берите его. Метод showModal() даёт из коробки: ловушку фокуса, закрытие по Escape, отрисовку поверх всего (top layer — никаких войн с z-index и overflow: hidden у предков), подложку ::backdrop, автоматическое отключение фона и возврат фокуса на элемент, с которого диалог открыли.
<button type="button" id="open">Удалить проект</button>
<dialog id="confirm" aria-labelledby="t">
<h2 id="t">Удалить проект?</h2>
<form method="dialog">
<button value="cancel" autofocus>Отмена</button>
<button value="confirm">Удалить</button>
</form>
</dialog>
const dialog = document.getElementById('confirm');
document.getElementById('open').addEventListener('click', () => {
dialog.showModal(); // не show()! show() открывает НЕмодально, без ловушки
});
dialog.addEventListener('close', () => {
if (dialog.returnValue === 'confirm') {
deleteProject();
}
});
Две ловушки в самом <dialog>: show() и showModal() — разные вещи (первый не запирает фокус и не блокирует фон), а autofocus лучше ставить на безопасную кнопку. В диалоге удаления фокус на «Удалить» — приглашение снести проект случайным нажатием Enter.
Ловушка руками: когда нативный <dialog> не подходит
Дизайн-система, анимации, сложная вложенность — причины бывают. Тогда цикл Tab придётся замкнуть самому.
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(',');
function getFocusable(root) {
return Array.from(root.querySelectorAll(FOCUSABLE))
// отсеиваем то, что скрыто: у скрытых элементов нет прямоугольников
.filter(el => el.getClientRects().length > 0);
}
function openDialog(overlay, trigger) {
const previouslyFocused = trigger || document.activeElement;
const dialog = overlay.querySelector('[role="dialog"]');
overlay.hidden = false;
document.body.append(overlay); // выносим из-под возможных overflow/transform
// 1. Фон недоступен целиком: и для Tab, и для мыши, и для скринридера
document.getElementById('app-root').inert = true;
// 2. Фокус — внутрь. Заголовок с tabindex="-1" читается целиком
const title = dialog.querySelector('h2');
(title || getFocusable(dialog)[0] || dialog).focus();
function onKeydown(e) {
if (e.key === 'Escape') {
e.preventDefault();
close();
return;
}
if (e.key !== 'Tab') return;
const items = getFocusable(dialog);
if (items.length === 0) {
e.preventDefault(); // некуда идти — держим фокус на диалоге
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
// 3. Замыкаем цикл в обе стороны
if (e.shiftKey && (active === first || active === title)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
function close() {
document.removeEventListener('keydown', onKeydown, true);
document.getElementById('app-root').inert = false;
overlay.hidden = true;
// 4. Возвращаем фокус туда, откуда пришли
if (previouslyFocused && previouslyFocused.isConnected) {
previouslyFocused.focus();
}
}
document.addEventListener('keydown', onKeydown, true);
return close;
}
Четыре пронумерованных шага в коде — это и есть весь контракт: отключить фон, увести фокус внутрь, замкнуть цикл Tab, вернуть фокус при закрытии. Обработчик вешаем на document в фазе перехвата (true) — иначе вложенные компоненты могут проглотить событие раньше.
inert против aria-hidden
Здесь ошибаются чаще всего, и ошибка коварная: она делает хуже, чем ничего.
inert | aria-hidden="true" | |
| Убирает из порядка Tab | Да, всё поддерево | Нет |
| Блокирует клики | Да | Нет |
| Прячет от скринридера | Да | Да |
| Отключает выделение текста | Да | Нет |
Если закрыть фон только атрибутом aria-hidden="true", вы получите худший из миров: Tab по-прежнему уходит на фон, но всё, что там есть, для скринридера стало невидимым. Пользователь жмёт Tab — и оказывается на элементе, о котором скринридер не может сказать ничего. Просто тишина. Это не «частично доступно», это хуже исходного состояния.
Правильный инструмент — inert: один атрибут выключает целое поддерево из интерфейса. Ставить его нужно на контейнер приложения (не на <body> — иначе выключите и саму модалку, если она лежит в body). Нативный <dialog>.showModal() делает это за вас.
<body>
<div id="app-root" inert>
<header>…</header>
<main>…</main>
</div>
<div class="overlay">
<div role="dialog" aria-modal="true" aria-labelledby="t">…</div>
</div>
</body>
Как это работает
Браузер строит единую последовательность фокуса на весь документ. Модальность — это не встроенное свойство «окна»: у страницы нет понятия «слой, из которого нельзя выйти». Есть только последовательность, и она сквозная. Поэтому «модальность» приходится собирать из трёх независимых механизмов:
- Порядок фокуса. Либо вы вырезаете фон из последовательности (
inert), либо перехватываете Tab и разворачиваете фокус назад руками. - Дерево доступности. Скринридер ходит не только по Tab: у него есть свой курсор, стрелки, режим чтения, навигация по заголовкам.
aria-modalиinertговорят ему, что читать сейчас нужно только диалог. - Слои отрисовки. Нативный
<dialog>сshowModal()уходит в top layer — отдельный слой поверх всего документа. Он не зависит отz-index,overflow: hiddenиtransformу родителей. Самописная модалка внутри карточки сoverflow: hiddenбудет обрезана — отсюда привычка выносить оверлеи в конец<body>порталом.
Отдельный вопрос — куда вернуть фокус, если кнопки-инициатора больше нет. Такое бывает постоянно: диалог «Удалить строку?» открыт кнопкой в самой строке, строку удалили — кнопки нет. Фокус улетает на <body>, и скринридер начинает читать страницу с начала. Правило: возвращайте фокус на ближайший осмысленный элемент, который пережил операцию — заголовок таблицы, соседнюю строку, кнопку «Добавить», — и сообщите результат через live region («Строка удалена»). Проверка previouslyFocused.isConnected из примера выше — минимальная страховка от такого сценария.
Частые ошибки
- Ловушка есть, Escape нет. Пользователь заперт по-настоящему. Escape закрывает диалог всегда.
- Фокус не уводят внутрь. Диалог открылся, фокус остался на кнопке под подложкой — все остальные усилия бессмысленны.
focus()на контейнере безtabindex="-1". Вызов молча ничего не делает, элемент не фокусируемый. Ошибки в консоли не будет — это самое неприятное.- Только
aria-hiddenна фоне. Tab уходит наружу, а там теперь тишина для скринридера. Нуженinert. - Обработчик
keydownне снимается при закрытии. После пары открытий Tab на всей странице начинает вести себя загадочно: несколько живых ловушек перехватывают его наперегонки. autofocusна деструктивной кнопке. Enter, привычно нажатый после открытия окна, удаляет проект. Фокус — на «Отмена» или на заголовок.- Фокус возвращается на удалённый элемент. Улетает на
<body>, скринридер теряет место. Возвращайте на живой соседний элемент. - Список фокусируемых элементов посчитан один раз при открытии. В диалоге появилась ошибка валидации, кнопка стала активной — а ловушка про них не знает. Пересчитывайте на каждый Tab.
- Toast-уведомление поверх модалки. Оно за пределами диалога — с клавиатуры до него не добраться. Такие сообщения делайте через live region внутри диалога.
Итоги
- Модальность — не свойство картинки, а поведение: фокус внутрь, цикл Tab, Escape, возврат фокуса, отключённый фон.
- Нативный
<dialog>+showModal()закрывает почти весь контракт бесплатно. Начинайте с него. aria-modal="true"— подсказка для скринридера, а не механизм. Сам он фокус не удержит.- Фон выключается атрибутом
inert.aria-hiddenв одиночку делает только хуже. - Escape обязателен: ловушка без выхода — это баг, а не фича.
- Запомните элемент-инициатор до открытия и верните фокус на него (или на живого соседа) после закрытия.
- Тест на 20 секунд: открыть диалог с клавиатуры, прожать Tab по кругу, нажать Escape и проверить, куда встал фокус.