Доступное модальное окно на HTML, CSS и JavaScript

Доступное модальное окно открывается и закрывается с клавиатуры, держит фокус внутри себя и получает от скринридера имя. Проще всего собрать его на элементе dialog: метод showModal() сам делает фон инертным и закрывает окно по Esc. Ниже рабочий код, который можно скопировать, и чек-лист проверки.

Окно, которое выскочило без действия пользователя, дезориентирует и раздражает. Человек с клавиатурой или скринридером теряет место на странице, если фокус остался под окном. Открывайте модалку только по клику или нажатию клавиши.

Модальное окно на элементе dialog

Элемент dialog стал основным способом: он работает в современных браузерах без библиотек. Модальный режим включает метод showModal().

Разметка занимает несколько строк. Кнопка вызывает окно, а внутри dialog лежат заголовок, текст и элементы управления.

<button type="button" id="open-btn">Подписаться на рассылку</button>

<dialog id="subscribe" aria-labelledby="subscribe-title" aria-describedby="subscribe-desc">
  <div class="modal-body">
    <h2 id="subscribe-title">Подписка на рассылку</h2>
    <p id="subscribe-desc">Раз в неделю мы присылаем подборку новых статей.</p>
    <form method="dialog">
      <label>Email
        <input type="email" name="email" required autocomplete="email">
      </label>
      <button value="cancel" formnovalidate>Отмена</button>
      <button value="ok">Подписаться</button>
    </form>
    <button type="button" class="modal-close" aria-label="Закрыть окно">×</button>
  </div>
</dialog>

Откройте окно из скрипта:

const dialog = document.getElementById('subscribe');
document.getElementById('open-btn').addEventListener('click', () => dialog.showModal());

Метод show() тоже открывает dialog, но окно остается немодальным: страница под ним живет как обычно, а Esc его не закрывает. Для модалки берите showModal().

Что делает showModal() сам:

  • помещает окно в верхний слой (top layer) поверх любых z-index;
  • делает остальной документ инертным: на фон нельзя попасть Tab-ом и кликом;
  • ставит начальный фокус внутрь окна;
  • закрывает окно по Esc.

Затемнение фона рисует псевдоэлемент ::backdrop. Стилизуйте его как обычный блок:

dialog {
  padding: 0;
  border: 0;
  border-radius: 8px;
  max-width: min(480px, 90vw);
}

dialog::backdrop {
  background: rgba(0, 0, 0, 0.6);
}

.modal-body {
  padding: 24px;
}

Нулевой padding у самого dialog нужен для клика по подложке: об этом в следующем разделе.

Как закрыть модальное окно

Закрыть окно должен мочь любой пользователь: мышью, клавиатурой, голосом. Браузер закрывает dialog по Esc сам, остальные способы добавляете вы.

Кнопка закрытия вызывает метод close():

dialog.querySelector('.modal-close').addEventListener('click', () => dialog.close());

Форма с method="dialog" закрывает окно без отправки данных на сервер. Значение нажатой кнопки попадает в свойство returnValue, поэтому в обработчике события close видно, чем закончился диалог: "ok" или "cancel".

dialog.addEventListener('close', () => {
  if (dialog.returnValue === 'ok') {
    // подписка оформлена
  }
});

Esc запускает событие cancel, а следом закрывает окно. Если в окне несохраненная форма, отмените закрытие через preventDefault() и спросите подтверждение. Но совсем отключать Esc нельзя: окно без выхода становится клавиатурной ловушкой.

Клик по подложке браузер сам не обрабатывает. Событие click приходит на сам dialog, а клик внутри окна приходит на вложенный элемент. Поэтому внутри dialog лежит обертка с отступами, а сам dialog без padding:

dialog.addEventListener('click', (event) => {
  if (event.target === dialog) {
    dialog.close();
  }
});

У dialog есть и атрибут closedby="any", который закрывает окно по клику вне него. Скрипт выше работает везде, где работает сам dialog, поэтому закрытие по подложке пишите им.

Управление фокусом в модальном окне

Фокус – место, где находится клавиатура пользователя. Окно должно забрать его при открытии и вернуть при закрытии.

Метод showModal() переносит фокус по правилам спецификации HTML. Сначала браузер ищет элемент с атрибутом autofocus. Если его нет, фокус получает первый фокусируемый элемент окна, а при его отсутствии – сам dialog. Для окна с длинным текстом начальный фокус на первой кнопке прокручивает текст вниз.

Поставьте autofocus на заголовок с tabindex="-1" или на кнопку закрытия, чтобы читатель начал с начала.

Закрытый модальный dialog возвращает фокус на элемент, который был активен до открытия. Так написано в спецификации, и современные браузеры это делают. Если кнопка-открыватель исчезла или вы перенесли фокус в обработчике, он потеряется. Добавьте страховку:

const openBtn = document.getElementById('open-btn');

dialog.addEventListener('close', () => {
  openBtn.focus();
});

Этого требует критерий WCAG 2.2 2.4.3 «Порядок фокуса» (уровень A): после закрытия окна читатель продолжает с того же места. Критерий 2.1.2 «Без клавиатурной ловушки» (уровень A) требует, чтобы из окна можно было выйти клавиатурой, то есть Esc или кнопка должны работать.

Фокус должен быть виден. Критерий 2.4.7 «Видимый фокус» (уровень AA) запрещает прятать его. Если убрали outline, верните рамку. Показывайте ее при навигации с клавиатуры через :focus-visible:

dialog button:focus-visible,
dialog input:focus-visible {
  outline: 3px solid #1a5fd0;
  outline-offset: 2px;
}

Доступное имя и роль диалога

Скринридер при открытии окна объявляет роль и имя: «диалог, Подписка на рассылку». Элемент dialog уже имеет роль dialog, поэтому role писать не нужно. Имя и описание задаете вы.

Атрибут aria-labelledby ссылается на id заголовка окна, а aria-describedby – на id абзаца с пояснением. Так скринридер читает видимый текст, и он совпадает с экраном. Атрибут aria-label подходит, когда заголовка на экране нет.

Для окна, которое прерывает работу и требует ответа, например «Удалить проект?», используйте role="alertdialog". Скринридеры объявляют такой диалог более настойчиво. Заголовок и описание у него обязательны, а кнопки конкретные: «Удалить» и «Оставить» вместо «Да» и «Нет».

<dialog id="confirm" role="alertdialog" aria-labelledby="confirm-title" aria-describedby="confirm-desc">
  <div class="modal-body">
    <h2 id="confirm-title">Удалить проект?</h2>
    <p id="confirm-desc">Восстановить проект после удаления нельзя.</p>
    <form method="dialog">
      <button value="cancel" autofocus>Оставить</button>
      <button value="delete">Удалить</button>
    </form>
  </div>
</dialog>

Фокус в таком окне ставьте на безопасную кнопку, как в примере: случайный Enter тогда не удалит данные.

Кнопка закрытия в виде крестика – это button. Div с onclick не получает фокус и не срабатывает по Enter. Значок × скринридер читает как «умножить» или пропускает, поэтому задайте aria-label="Закрыть окно" на русском.

Модальное окно без dialog

Старую модалку на div не всегда можно заменить. Тогда ее нужно доработать вручную: dialog делал это за вас, а теперь каждый пункт ваш.

Нужны пять вещей: роль, инертный фон, перенос фокуса, ловушка фокуса и закрытие по Esc.

  • Окну даем role="dialog", aria-modal="true" и aria-labelledby. Атрибут aria-modal сообщает скринридеру о модальности, но фон сам не отключает: это остается на вас.
  • Фон отключаем атрибутом inert. Он убирает содержимое из порядка фокуса, из дерева доступности и закрывает его от кликов. Перебор tabindex="-1" и aria-hidden на каждом элементе больше не нужны.
  • Окно размещаем рядом с контейнером страницы, а не внутри него, иначе inert отключит и само окно.
  • Tab на последнем элементе переводит фокус на первый, Shift+Tab на первом – на последний.
  • Esc определяем по event.key === 'Escape'. Свойство keyCode устарело, а в старом коде его легко сломать опечаткой.
<div id="page">
  <button type="button" id="open-legacy">Открыть окно</button>
  <!-- остальная страница -->
</div>

<div id="legacy-modal" role="dialog" aria-modal="true"
     aria-labelledby="legacy-title" tabindex="-1" hidden>
  <h2 id="legacy-title">Настройки</h2>
  <button type="button" id="legacy-close">Закрыть</button>
</div>
const page = document.getElementById('page');
const modal = document.getElementById('legacy-modal');
const opener = document.getElementById('open-legacy');
let lastFocus = null;

const focusableSelector =
  'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';

function openModal() {
  lastFocus = document.activeElement;
  page.inert = true;
  modal.hidden = false;
  modal.focus();
  document.addEventListener('keydown', onKeydown);
}

function closeModal() {
  document.removeEventListener('keydown', onKeydown);
  modal.hidden = true;
  page.inert = false;
  if (lastFocus) lastFocus.focus();
}

function onKeydown(event) {
  if (event.key === 'Escape') {
    closeModal();
    return;
  }
  if (event.key !== 'Tab') return;

  const items = modal.querySelectorAll(focusableSelector);
  if (items.length === 0) {
    event.preventDefault();
    return;
  }
  const first = items[0];
  const last = items[items.length - 1];

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

opener.addEventListener('click', openModal);
document.getElementById('legacy-close').addEventListener('click', closeModal);

Фокус сначала уходит на контейнер окна: у него tabindex="-1", и скринридер сразу читает имя диалога. Ловушку фокуса при inert на фоне ставят ради цикла Tab внутри окна, иначе фокус уходит в интерфейс браузера. Выйти оттуда можно по Esc, значит, критерий 2.1.2 соблюден.

Блокировка прокрутки страницы под окном

Открытое окно не должно давать прокручивать страницу под ним. showModal() делает фон инертным, но колесо мыши и жест прокрутки по-прежнему могут двигать документ, если окно короче экрана.

Самый простой способ – overflow: hidden на body, пока окно открыто. Полоса прокрутки при этом исчезает, и страница дергается вправо. Свойство scrollbar-gutter: stable резервирует под полосу место заранее.

html {
  scrollbar-gutter: stable;
}

body.modal-open {
  overflow: hidden;
}
dialog.addEventListener('close', () => document.body.classList.remove('modal-open'));

document.getElementById('open-btn').addEventListener('click', () => {
  document.body.classList.add('modal-open');
  dialog.showModal();
});

Класс снимается в обработчике close, поэтому страница разблокируется и после Esc, и после отправки формы.

В iOS Safari фон под окном иногда продолжает прокручиваться и при overflow: hidden. Проверьте окно на iPhone: если фон все же едет, зафиксируйте body через position: fixed на время показа окна и верните прежнюю позицию прокрутки при закрытии.

Полный пример доступного модального окна

Один файл собирает все сказанное. Сохраните его как modal.html и откройте в браузере: окно открывается кнопкой, закрывается кнопкой, Esc, подложкой и формой, а фокус возвращается на кнопку.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Доступное модальное окно</title>
<style>
  html { scrollbar-gutter: stable; }
  body.modal-open { overflow: hidden; }

  dialog {
    padding: 0;
    border: 0;
    border-radius: 8px;
    max-width: min(480px, 90vw);
  }
  dialog::backdrop { background: rgba(0, 0, 0, 0.6); }
  .modal-body { position: relative; padding: 24px; }
  .modal-close {
    position: absolute;
    top: 8px;
    right: 8px;
    width: 40px;
    height: 40px;
    font-size: 24px;
  }
  button:focus-visible,
  input:focus-visible {
    outline: 3px solid #1a5fd0;
    outline-offset: 2px;
  }
</style>
</head>
<body>
<main>
  <h1>Блог о вёрстке</h1>
  <button type="button" id="open-btn">Подписаться на рассылку</button>
</main>

<dialog id="subscribe" aria-labelledby="subscribe-title" aria-describedby="subscribe-desc">
  <div class="modal-body">
    <h2 id="subscribe-title">Подписка на рассылку</h2>
    <p id="subscribe-desc">Раз в неделю мы присылаем подборку новых статей.</p>
    <form method="dialog">
      <label>Email
        <input type="email" name="email" required autocomplete="email">
      </label>
      <button value="cancel" formnovalidate>Отмена</button>
      <button value="ok">Подписаться</button>
    </form>
    <button type="button" class="modal-close" aria-label="Закрыть окно">×</button>
  </div>
</dialog>

<script>
  const dialog = document.getElementById('subscribe');
  const openBtn = document.getElementById('open-btn');

  openBtn.addEventListener('click', () => {
    document.body.classList.add('modal-open');
    dialog.showModal();
  });

  dialog.querySelector('.modal-close').addEventListener('click', () => dialog.close());

  dialog.addEventListener('click', (event) => {
    if (event.target === dialog) dialog.close();
  });

  dialog.addEventListener('close', () => {
    document.body.classList.remove('modal-open');
    openBtn.focus();
  });
</script>
</body>
</html>

Как проверить доступность модального окна

Автоматические проверки ловят только часть ошибок. Фокус и порядок чтения проверяйте руками: сначала клавиатурой, затем скринридером. Отключите мышь и пройдите по списку:

  1. Tab доходит до кнопки-открывателя, Enter и пробел открывают окно.
  2. Фокус сразу внутри окна, его хорошо видно.
  3. Tab и Shift+Tab ходят по кругу и не выводят на фон.
  4. Esc закрывает окно.
  5. После закрытия фокус стоит на кнопке-открывателе.
  6. Страница под окном не прокручивается.

Затем включите скринридер. На Windows скачайте бесплатный NVDA. На macOS встроен VoiceOver, его включает сочетание Cmd+F5, а подробности есть в справке Apple.

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

Автоматика закрывает остальное. Расширение axe DevTools для Chrome находит окна без имени, кнопки без подписи и низкий контраст. Lighthouse в Chrome DevTools дает похожий отчет по доступности. Дерево доступности во вкладке Accessibility покажет, видит ли браузер роль dialog и имя окна.

Эталон поведения описывает паттерн Dialog (Modal) в WAI-ARIA Authoring Practices: фокус уходит внутрь окна, Tab ходит по кругу, Esc закрывает, а при закрытии фокус возвращается на вызвавший элемент.

Ошибки при верстке модальных окон

Чаще всего ломаются одни и те же места.

  • Кнопка закрытия – div с onclick. Замените на button с aria-label.
  • Фокус остался на странице под окном. Откройте окно через showModal() или переведите фокус в окно вручную.
  • После закрытия фокус пропал. Запомните document.activeElement при открытии и верните его в close.
  • Окно не закрыть без мыши. Оставьте Esc и видимую кнопку закрытия.
  • Фон под окном активен. Используйте showModal() или inert вместе с aria-modal.
  • Окно без имени. Свяжите его с заголовком через aria-labelledby.
  • Esc определяется по keyCode === 27. Пишите event.key === 'Escape'.
  • Outline убран без замены. Задайте :focus-visible.
  • Окно показывается само при загрузке страницы. Открывайте его по действию пользователя.

Комментарии

Оставьте свой комментарий
Пока никто не оставил комментариев