Блог

Статьи

Как сделать модальное окно на HTML, CSS и JS: элемент dialog

Модальное окно собирается на элементе dialog: браузер сам затемняет страницу, удерживает фокус и закрывает окно по Esc. Показываем код, вариант без JavaScript и поддержку браузеров.

Модальное окно на HTML, CSS и JS собирается на элементе dialog. Метод showModal() открывает окно поверх страницы, затемняет её и блокирует, а клавиша Esc закрывает окно без нашего кода. Открыть и закрыть окно можно и без JavaScript, атрибутами commandfor и command у кнопки. Закрытие по клику мимо окна включает атрибут closedby, а в Safari для этого пока нужен запасной обработчик.

Коротко

  • Элемент dialog работает во всех основных браузерах с марта 2022 года: Chrome 37, Firefox 98, Safari 15.4.
  • showModal() открывает окно как модальное: поверх страницы, с затемнением ::backdrop и закрытием по Esc. После закрытия браузер возвращает фокус на кнопку, которая открыла окно.
  • Кнопка с атрибутами commandfor и command="show-modal" открывает окно без JavaScript. Атрибуты понимают Chrome и Edge 135, Firefox 144 и Safari 26.2.
  • closedby="any" закрывает окно по клику на затемнение в Chrome и Edge 134 и в Firefox 141. В стабильном Safari атрибут не работает.
  • Ответ пользователя приходит в свойство returnValue. Перед каждым открытием его стоит очищать.
  • Страница под открытым окном продолжает прокручиваться. Прокрутку отключает одно правило CSS.

Как сделать модальное окно на dialog

Модальное, или всплывающее, окно собирается из элемента dialog с содержимым, кнопки на странице и вызова showModal() по клику. Пока окно закрыто, браузер его не показывает.

<button class="open" type="button">Открыть окно</button>

<dialog class="modal">
  <h2>Модальное окно</h2>
  <p>Страница под окном недоступна, пока оно открыто. Закрыть можно кнопкой или клавишей Esc.</p>
  <button class="close" type="button">Закрыть</button>
</dialog>
const modal = document.querySelector('.modal');

document.querySelector('.open').addEventListener('click', () => {
  modal.showModal();
});

document.querySelector('.close').addEventListener('click', () => {
  modal.close();
});

После вызова showModal() браузер сам делает то, что в окне на div приходится писать руками:

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

Мы проверили пример в Chrome 154. После клика фокус стоит на кнопке «Закрыть», после Esc он возвращается на кнопку «Открыть окно». Клик в точку, где под затемнением лежит кнопка «Открыть окно», до неё не доходит.

В живом примере окну уже заданы ширина, отступы и цвет затемнения. Эти стили разобраны в следующем разделе.

penlab.space/pen/EZ90e5Uh

Под примером стоит ссылка на пен: он открывается в редакторе Penlab, и код в нём можно менять.

Открыть dialog можно тремя способами, и модальным окно получается только в одном:

  • showModal() открывает модальное окно: с затемнением, блокировкой страницы и закрытием по Esc.
  • show() открывает окно без затемнения и без блокировки страницы. Клавиша Esc такое окно не закрывает.
  • Атрибут open, поставленный в разметке или добавленный из скрипта, даёт то же немодальное окно, что и show().

Руками мы open не ставим и не снимаем. По спецификации HTML после снятия open у модального окна событие close не придёт, и страница останется заблокированной.

Как оформить окно и затемнение

У dialog есть стили браузера: сплошная рамка, отступы в 1em, центрирование через margin: auto и почти прозрачное затемнение. Обычно мы убираем рамку, задаём ширину и делаем фон ::backdrop темнее.

.modal {
  width: min(90vw, 360px);
  padding: 24px;
  border: 0;
  border-radius: 8px;
}

.modal::backdrop {
  background-color: rgb(0 0 0 / 0.5);
}

.modal h2 {
  margin-top: 0;
}

В стилях браузера затемнение задано как rgba(0, 0, 0, 0.1), то есть чёрный цвет с непрозрачностью 10%. На светлой странице его почти не видно, поэтому фон ::backdrop задаём свой.

Стили браузера чаще всего дают о себе знать в таких случаях:

  • Окно уехало в угол. По центру его держит margin: auto из стилей браузера. Общий сброс margin: 0 центрирование отключает.
  • Закрытое окно видно на странице. Его прячет правило браузера dialog:not([open]) { display: none; }. Наше display: flex или display: grid на селекторе .modal это правило перебивает. Раскладку пишем на .modal[open] или на блоке внутри окна.
  • Окно не растягивается на весь экран. У модального окна браузер ограничивает размеры: max-width и max-height равны calc(100% - 6px - 2em). Для окна во весь экран оба свойства переопределяем, например значением 100%.

Плавное появление окна и затемнения тоже пишется на CSS. Этот код мы разобрали в статье «Плавное появление блока в CSS: как анимировать display: none».

Как открыть и закрыть окно без JavaScript

Кнопку связывают с элементом dialog атрибуты commandfor и command. В первый записываем id окна, во второй - действие: show-modal открывает окно как модальное, close закрывает его.

<button type="button" commandfor="modal" command="show-modal">Открыть окно</button>

<dialog id="modal" class="modal" closedby="any">
  <h2>Окно без JavaScript</h2>
  <p>Кнопки связаны с окном атрибутами. Там, где работает closedby, окно закрывает и клик по затемнению.</p>
  <button type="button" commandfor="modal" command="close">Закрыть</button>
</dialog>

Окно открывается так же, как после showModal(): с затемнением, заблокированной страницей и закрытием по Esc. Атрибуты commandfor и command работают в Chrome и Edge с версии 135, в Firefox с версии 144 и в Safari с версии 26.2.

В живом примере у окна те же стили, что и в предыдущем разделе, а панели JS в нём нет.

penlab.space/pen/ks3C4fsx

Кнопке внутри формы нужен type="button". Если кнопка с commandfor стоит внутри формы и type у неё не указан, команда не выполняется: в Chrome 154 такая кнопка не закрыла окно и не отправила форму.

В браузере без поддержки commandfor у кнопки нет действия: незнакомый атрибут браузер пропускает. В таком браузере мы пример не запускали, это следует из правил разбора HTML. Если на сайт заходят с таких браузеров, добавляем проверку и открываем окно из скрипта:

const modal = document.querySelector('#modal');
const openButton = document.querySelector('[command="show-modal"]');

if (!('commandForElement' in HTMLButtonElement.prototype)) {
  openButton.addEventListener('click', () => {
    modal.showModal();
  });
}

Кнопку закрытия в этом случае надёжнее положить в форму с method="dialog": такая форма закрывает окно во всех браузерах, где есть dialog.

<form method="dialog">
  <button>Закрыть</button>
</form>

Как закрыть окно по клику на фон

За клик мимо окна отвечает атрибут closedby у элемента dialog. Со значением any окно закрывается и по Esc, и по клику на затемнение. Отдельный обработчик не нужен.

У атрибута три значения:

  • any - сработают и Esc, и клик мимо окна.
  • closerequest - только Esc и системные способы закрытия, например кнопка «Назад» на Android. Так ведёт себя окно, открытое через showModal(), когда атрибут не указан.
  • none - закрыть окно могут только наши кнопки и скрипт.

Атрибут closedby работает в Chrome и Edge с версии 134 и в Firefox с версии 141. В Safari он появился в апреле 2026 года в Technology Preview 242, предварительной сборке браузера для разработчиков, и в стабильном Safari 27 его нет. Для Safari и для старых версий остальных браузеров пишем запасной обработчик.

У клика по затемнению event.target - сам элемент dialog. Тот же target даёт клик по внутренним отступам окна. Мы обнуляем padding у dialog и переносим отступы на блок внутри него, чтобы любой клик внутри окна попадал во вложенный элемент. В нашем примере этот блок - форма с кнопками окна.

<dialog class="modal">
  <form method="dialog">
    <p>Содержимое окна</p>
    <button>Закрыть</button>
  </form>
</dialog>
.modal {
  padding: 0;
}

.modal form {
  padding: 24px;
}
const modal = document.querySelector('.modal');

if (!('closedBy' in HTMLDialogElement.prototype)) {
  let pressedOnBackdrop = false;

  modal.addEventListener('pointerdown', (event) => {
    pressedOnBackdrop = event.target === modal;
  });

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

Обработчик pointerdown нужен для одного случая. Пользователь выделяет текст в окне, ведёт мышь за его край и отпускает кнопку над затемнением. Событие click при этом приходит на dialog, и обработчик, который проверяет только event.target, закроет окно. Мы воспроизвели это в Chrome 154: вариант без pointerdown закрыл окно, вариант с ним оставил открытым.

Оба обработчика мы проверяли в Chrome 154 на окне без атрибута closedby и без условия if: в этом браузере свойство closedBy есть, и код под условием не выполняется. В Safari вживую их не запускали.

Как узнать, что ответил пользователь

Кнопки окна кладём в форму с method="dialog". Такая форма никуда не отправляется: она закрывает окно и записывает значение value нажатой кнопки в свойство returnValue. Читаем его в обработчике события close.

<dialog class="modal">
  <form method="dialog">
    <h2>Удалить черновик?</h2>
    <button value="cancel">Отмена</button>
    <button value="delete">Удалить</button>
  </form>
</dialog>
const modal = document.querySelector('.modal');

modal.addEventListener('close', () => {
  console.log(modal.returnValue); // 'cancel' или 'delete'
});

Событие close приходит при любом закрытии: кнопкой формы, клавишей Esc, кликом по затемнению, вызовом close(). Значение можно передать и из скрипта, modal.close('delete'), и через value кнопки с command="close".

Тут есть нюанс. По спецификации Esc и клик мимо окна returnValue не меняют, в свойстве остаётся значение с прошлого закрытия. Мы проверили такую последовательность в Chrome 154: нажали «Удалить», открыли окно снова и кликнули по затемнению. В returnValue осталось delete, и страница сообщила об удалении, хотя пользователь отказался.

С клавишей Esc браузеры расходятся. В нашем тесте Chrome 154 после Esc записал в свойство пустую строку, а в вопросе на Stack Overflow описан Firefox 143, который оставил прошлое значение. Чтобы результат не зависел от браузера и способа закрытия, перед каждым открытием свойство очищаем:

modal.returnValue = '';
modal.showModal();

Куда поставить фокус и как дать окну имя

Без подсказок браузер ставит фокус на первый элемент окна, до которого можно дойти клавишей Tab. Нужный элемент мы отмечаем атрибутом autofocus. В окне подтверждения это кнопка отмены: случайное нажатие Enter ничего не удалит.

<dialog class="modal" aria-labelledby="modal-title">
  <form method="dialog">
    <h2 id="modal-title">Удалить черновик?</h2>
    <button value="cancel" autofocus>Отмена</button>
    <button value="delete">Удалить</button>
  </form>
</dialog>

Атрибут aria-labelledby связывает окно с его заголовком, и программа чтения с экрана при открытии называет окно этим текстом. Роль dialog, по которой программа чтения с экрана узнаёт окно, у элемента уже есть. Окно, открытое через showModal(), браузер сам помечает как модальное. Писать role="dialog" и aria-modal="true" на dialog не нужно. Атрибут tabindex на нём спецификация запрещает.

Видимую кнопку закрытия мы оставляем в каждом окне: клавиша Esc есть не на всех устройствах. С программами чтения с экрана мы примеры не проверяли, этот раздел опирается на спецификацию HTML, MDN и руководство W3C по модальным окнам.

Как запретить прокрутку страницы под окном

Метод showModal() блокирует клики и фокус, но прокрутку страницы оставляет. Её отключает одно правило с селектором :has(): пока на странице есть открытое модальное окно, у html стоит overflow: hidden.

html:has(dialog:modal) {
  overflow: hidden;
}

В Chrome 154 на странице высотой 3000 пикселей без этого правила колесо мыши над затемнением прокрутило страницу на 400 пикселей. С правилом страница осталась на месте.

Селектор :has() работает в Chrome и Edge с версии 105, в Firefox с версии 121 и в Safari с версии 15.4. Псевдокласс :modal - в Chrome и Edge 105, Firefox 103 и Safari 15.6.

Вместе с прокруткой исчезает полоса прокрутки, и там, где она занимает место, страница сдвигается вбок. Место под полосу резервирует scrollbar-gutter: stable на элементе html. Свойство есть в Chrome и Edge 94, Firefox 97 и Safari 18.2.

В каких браузерах работают dialog, commandfor и closedby

Базовое окно на dialog работает во всех актуальных браузерах. От версии зависят открытие без JavaScript и закрытие по клику на фон.

Данные собраны 12 октября 2026 года по таблицам совместимости MDN. Стабильные версии на этот день: Chrome и Edge 155, Firefox 157, Safari 27.

  • dialog, showModal(), close(), returnValue, ::backdrop и форма с method="dialog": Chrome 37, Edge 79, Firefox 98, Safari 15.4.
  • Атрибуты commandfor и command: Chrome и Edge 135, Firefox 144, Safari 26.2.
  • Атрибут closedby: Chrome и Edge 134, Firefox 141. В Safari только в предварительной сборке Technology Preview 242.
  • Селектор :has(): Chrome и Edge 105, Firefox 121, Safari 15.4.
  • Псевдокласс :modal: Chrome и Edge 105, Firefox 103, Safari 15.6.

Примеры из статьи мы запускали в Chrome 154. В Firefox и Safari вживую их не проверяли, поведение в этих браузерах описано по документации.

Готовое окно подтверждения: код целиком

Окно подтверждения собрано из частей, разобранных выше: форма с method="dialog", фокус на кнопке отмены, закрытие по клику на фон с запасным кодом, запрет прокрутки и вывод ответа на страницу. Открывает окно скрипт, потому что перед открытием он очищает returnValue.

<button class="open" type="button">Удалить черновик</button>
<p class="result">Ответа пока нет.</p>

<dialog class="modal" closedby="any" aria-labelledby="modal-title">
  <form method="dialog">
    <h2 id="modal-title">Удалить черновик?</h2>
    <p>Вернуть его после удаления не получится.</p>
    <div class="actions">
      <button value="cancel" autofocus>Отмена</button>
      <button value="delete">Удалить</button>
    </div>
  </form>
</dialog>
.modal {
  width: min(90vw, 360px);
  padding: 0;
  border: 0;
  border-radius: 8px;
}

.modal form {
  padding: 24px;
}

.modal::backdrop {
  background-color: rgb(0 0 0 / 0.5);
}

.modal h2 {
  margin-top: 0;
}

.actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}

html:has(dialog:modal) {
  overflow: hidden;
}
const modal = document.querySelector('.modal');
const result = document.querySelector('.result');

document.querySelector('.open').addEventListener('click', () => {
  modal.returnValue = '';
  modal.showModal();
});

modal.addEventListener('close', () => {
  if (modal.returnValue === 'delete') {
    result.textContent = 'Черновик удалён.';
  } else {
    result.textContent = 'Удаление отменено.';
  }
});

// Запасной вариант для браузеров без атрибута closedby
if (!('closedBy' in HTMLDialogElement.prototype)) {
  let pressedOnBackdrop = false;

  modal.addEventListener('pointerdown', (event) => {
    pressedOnBackdrop = event.target === modal;
  });

  modal.addEventListener('click', (event) => {
    if (pressedOnBackdrop && event.target === modal) {
      modal.close();
    }
  });
}
penlab.space/pen/FCXHDgMx

На этом пене можно проверить, зачем нужна очистка returnValue. Открываем его в редакторе Penlab по ссылке под примером и повторяем шаги:

  1. Убираем из кода строку modal.returnValue = '';.
  2. Нажимаем «Удалить черновик», затем «Удалить».
  3. Открываем окно снова.
  4. Кликаем по затемнению: на странице остаётся «Черновик удалён.», хотя в этот раз мы отказались. Если вернуть строку из шага 1, тот же клик меняет надпись на «Удаление отменено.».

Своё окно с нуля удобно собрать в новом пене. Готовый пен можно отправить ссылкой или встроить на свою страницу. Об этом есть заметка «Поделиться пеном и встроить его на свой сайт».

FAQ

Нужно ли ещё делать модальное окно на div?

В большинстве случаев нет. Элемент dialog работает во всех основных браузерах с марта 2022 года, а окну на div приходится вручную добавлять то, что dialog даёт сразу: блокировку страницы, управление фокусом и закрытие по Esc.

Чем dialog отличается от popover?

Модальное окно dialog блокирует страницу под собой, блок с атрибутом popover оставляет её доступной. Для меню, подсказок и выпадающих списков подходит popover. Делать такие элементы на dialog спецификация HTML называет ошибкой.

Почему окно не закрывается после close()?

Частая причина - правило display: flex или display: grid на самом dialog. Оно перебивает правило браузера, которое прячет окно без атрибута open. Окно закрыто, но остаётся на экране. Раскладку переносим на селектор dialog[open] или на блок внутри окна.

Как плавно открыть и закрыть модальное окно?

Плавное открытие и закрытие делается на CSS: правило @starting-style задаёт начальный стиль, а display и overlay в списке переходов удерживают окно на экране, пока идёт скрытие. Код для dialog и ::backdrop есть в статье «Плавное появление блока в CSS: как анимировать display: none».

Можно ли запретить закрытие по Esc?

Да, атрибутом closedby="none" в браузерах, которые его поддерживают. В Chrome 154 окно с этим значением не закрылось ни по Esc, ни по клику на затемнение. Второй способ - отменить событие cancel через preventDefault(), но по спецификации отмена срабатывает не всегда: если между нажатиями Esc пользователь ничего не делал на странице, браузер вправе закрыть окно. Мы бы оставляли Esc рабочим везде, где закрытие окна ничего не ломает.

Можно ли положить в окно форму с полями?

Да. Форма с method="dialog" данные на сервер не отправляет: она закрывает окно, а значения полей остаются в форме, и их можно прочитать в обработчике события close. В Chrome 154 поле сохранило введённый текст после закрытия окна такой формой.

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

Модальное окно рисуется в верхнем слое, и z-index остальных элементов страницы на этот слой не влияет. Всё за пределами окна к тому же инертно: не отвечает на клики и клавишу Tab. Уведомление, которое должно быть видно поверх окна, размещаем внутри самого dialog.

Источники