Привет, друзья!
В этой небольшой статье я расскажу вам о новом свойстве события toggle — source, а также о новом атрибуте HTML-элемента dialog — closedby.
Свойство source позволяет определять источник переключения видимости поповера (popover), а атрибут closedby позволяет декларативно управлять логикой закрытия dialog, но обо всем по порядку.
❯ ToggleEvent.source
Доступное только для чтения свойство source интерфейса ToggleEvent - это экземпляр объекта Element, представляющий собой элемент управления поповером (popover control element), инициировавший переключение (toggle) видимости поповера.
На сегодняшний день это свойство поддерживается всеми основными браузерами (в Safari пока только в качестве экспериментальной возможности):

Элементом управления поповером может быть:
<button>с атрибутом commandfor или popovertarget<input type="button">с атрибутомpopovertarget
Поповерами в данном контексте являются следующие элементы:
dialogлюбой элемент с атрибутом popover
Если видимость поповера переключается программно, например, с помощью метода showPopover, source будет иметь значение null.
Возьмем пример из заметки про Invoker Commands API с dialog, отображаемым при нажатии кнопки с атрибутами commandfor и command, и немного расширим его:
<div> <button commandfor="my-dialog" command="show-modal"> Show modal dialog </button> <dialog id="my-dialog"> <h3>Do you like modern Web APIs?</h3> <div style="display: flex; gap: 10px"> <button commandfor="my-dialog" command="close" data-answer="yes"> Yes </button> <button commandfor="my-dialog" command="close" data-answer="sure"> Sure </button> </div> </dialog> <p>No answer yet</p> </div>
В dialog у нас имеется две кнопки для его закрытия. Наша задача — вывести значение атрибута data-answer в <p>. Сделать это проще простого:
// Ссылка на параграф const $p = document.querySelector("p"); // Обрабатываем переключение видимости `dialog` document.querySelector("dialog").addEventListener("toggle", (e) => { // Ничего не делаем, если видимость переключается программно if (!(e.source instanceof HTMLButtonElement)) return; const { answer } = e.source.dataset; // Нас интересуют только кнопки закрытия if (answer) { $p.textContent = `Answer: ${answer}`; } });
Мелочь, а приятно, согласитесь?
❯ HTMLDialogElement.closedBy
Атрибут closedby элемента dialog (свойство closedBy интерфейса HTMLDialogElement) определяет, какие действия пользователя приводят к закрытию dialog.
На сегодняшний день этот атрибут поддерживается всеми основными браузерами (в Safari пока только в качестве экспериментальной возможности):

Существует три метода закрытия dialog:
Клик за его пределами, по затенению (overlay) при наличии (light dismiss — легкая отмена; аналогично
popover="auto").Действие пользователя, специфичное для платформы, например, нажатие клавиши
Escна десктопе.Механизм, определенный разработчиком, например, кнопка с обработчиком клика, вызывающая HTMLDialogElement.close().
closedby принимает следующие значения:
any—dialogзакрывается всеми указанными выше методамиcloserequest—dialogзакрывается методами 2 и 3none—dialogзакрывается только методом 3
Дефолтным значением closedby является:
closerequest— еслиdialogоткрыт с помощью метода showModalnone— в других случаях
Таким образом, closedby - это еще один недостающий пазл полностью декларативного модального окна: до его появления механизм закрытия dialog при клике по оверлею можно было реализовать только с помощью JavaScript (хук useClickOutside в React и т.п.).
Реализуем три модальных окна с разными closedby с помощью только HTML и CSS:
<button commandfor="dialog-first" command="show-modal" class="open"> Open first dialog </button> <!-- Первое модальное окно --> <dialog id="dialog-first" closedby="any"> <div class="dialog-content"> <div class="dialog-header"> <h3>First dialog</h3> <button commandfor="dialog-first" command="close">✖</button> </div> <p> This is the first dialog. Click outside, press Esc, or click the "Close" button to close it. </p> <div class="dialog-footer"> <button commandfor="dialog-first" command="close" class="cancel"> Close </button> <button class="confirm" commandfor="dialog-second" command="show-modal" > Open second dialog </button> </div> </div> </dialog> <!-- Второе модальное окно --> <!-- В данном случае `closedby` можно опустить --> <dialog id="dialog-second" closedby="closerequest"> <div class="dialog-content"> <div class="dialog-header"> <h3>Second dialog</h3> <button commandfor="dialog-second" command="close">✖</button> </div> <p> This is the second dialog. Press Esc or click the "Close" button to close it. </p> <div class="dialog-footer"> <button commandfor="dialog-second" command="close" class="cancel"> Close </button> <button class="confirm" commandfor="dialog-third" command="show-modal" > Open third dialog </button> </div> </div> </dialog> <!-- Третье модальное окно --> <dialog id="dialog-third" closedby="none"> <div class="dialog-content"> <div class="dialog-header"> <h3>Third dialog</h3> <button commandfor="dialog-third" command="close">✖</button> </div> <p>This is the third dialog. Click the "Close" button to close it.</p> <div class="dialog-footer"> <button commandfor="dialog-third" command="close" class="cancel"> Close </button> </div> </div> </dialog>
Добавим немного стилей для красоты:
:root { --p: 4px; } html, body { height: 100%; } body { margin: 0; display: flex; justify-content: center; align-items: center; font-family: system-ui, sans-serif; } /* Диалог */ dialog { padding: 0; background: none; border: none; box-shadow: 0 var(--p) calc(var(--p) * 2) rgba(0, 0, 0, 0.1); /* Затенение */ &::backdrop { background: rgba(0, 0, 0, 0.15); } } /* Содержимое диалога */ .dialog-content { padding: calc(var(--p) * 4); background: white; border-radius: var(--p); min-width: 320px; max-width: 480px; display: flex; flex-direction: column; gap: calc(var(--p) * 4); & > p { margin: 0; } } /* Шапка диалога */ .dialog-header { display: flex; justify-content: space-between; align-items: center; gap: calc(var(--p) * 2); & > h3 { margin: 0; } & > button { padding: var(--p); background: none; font-size: 1rem; } } /* Подвал диалога */ .dialog-footer { display: flex; justify-content: flex-end; gap: calc(var(--p) * 2); & > button { &.confirm { background-color: #198754; color: white; } &.cancel { background-color: #dc3545; color: white; } } } /* Кнопка */ button { padding: calc(var(--p) * 2) calc(var(--p) * 4); border: none; border-radius: var(--p); cursor: pointer; &:focus-visible { outline: 2px solid black; } /* Кнопка открытия диалога */ &.open { background-color: #0d6efd; color: white; } }
Легко и просто.
Заметили проблему? Обратите внимание на затенение. Поскольку оно у нас прозрачное на 85% (rgba(0, 0, 0, 0.15)), оверлеи открытых dialog "умножаются", т.е. общий визуальный оверлей страницы с каждым новым открытым dialog становится все темнее и темнее. Хотелось бы этого избежать. Хотелось бы отображать только верхний оверлей.
К сожалению, насколько мне известно, на сегодняшний день средствами CSS эту проблему не решить. Вероятно, в будущем у нас появится специальный селектор для таких кейсов, поскольку браузер точно знает, какой dialog открыт последним.

Chrome добавляет тег top-layer(n) рядом с <dialog>, где n — порядковый номер открытого dialog, начиная с 1. Кроме того, под разметкой появляется стек открытых dialog - #top-layer, в котором последний открытый dialog находится в самом низу (стек растет вниз).
Самое простое решение — класс CSS и MutationObserver.
Правим стили:
/* Затенение */ &::backdrop { background: transparent; } &.active::backdrop { background: rgba(0, 0, 0, 0.15); }
Оверлей будет отображаться только у самого верхнего dialog с классом active.
Следим за изменением атрибутов dialog:
// Стек открытых диалогов let dialogs = [] const mutationObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { // Нас интересует только этот атрибут if (mutation.attributeName === 'open') { const dialog = mutation.target // Добавляем или удаляем диалог из стека if (dialog.open) { dialogs.push(dialog) } else { dialogs = dialogs.filter((d) => d !== dialog) } dialogs.forEach((d, i) => { // Активируем верхний диалог if (i === dialogs.length - 1) { d.classList.add('active') } else { d.classList.remove('active') } }) } }) }) // Включаем наблюдение document.querySelectorAll('dialog').forEach((d) => { mutationObserver.observe(d, { attributes: true }) })
Надеюсь, в будущем подобное можно будет реализовать с помощью одного правила CSS. И это мы еще не анимируем dialog и оверлей.
Это все, чем я хотел поделиться с вами в этой небольшой заметке. Надеюсь, вы узнали что-то новое и, следовательно, не зря потратили время.
Happy coding!

Перед оплатой в разделе «Бонусы и промокоды» в панели управления активируйте промокод и получите кэшбэк на баланс.
Комментарии (6)

oeditus
26.02.2026 16:28источник переключения видимости поповера (popover)
Эх, какой кликбейт мог бы быть: «Кому поклоняется попове́р?»
P. S. Текст отличный, ему кликбейт не нужен, разумеется.
codebra
Отличный обзор! Про
ToggleEvent.sourceдаже не задумывался, а ведь и правда элегантное решение для многих сценариев. Ноclosedbyдля меня лично главное открытие. Сколько кода писал, чтобы закрывать модалки по клику на оверлей, а тут просто атрибут в HTML и готово.Такие статьи особенно ценны, когда показывают, как современные API сокращают код и убирают зависимость от JS.
Кстати, вдохновившись твоим примером, теперь думаю добавить на Codebra небольшое практическое задание как раз на отработку
closedbyиToggleEvent. Хочется сделать пример, где нужно переделать старую модалку, которая висела на JS-костылях, на чистый HTML с новыми атрибутами и через событиеtoggleвыводить, какой именно кнопкой её закрыли.Спасибо за статью, буду ссылаться на неё в пояснениях к заданию, когда сделаю!
aio350 Автор
Спасибо. Я уже устал объяснять коллегам преимущества использования dialog перед div для модалок) Хуже только доказывать, что не надо использовать redux в современный приложениях)
codebra
Полностью согласен. Думаю чтобы не объяснять коллегам, им нужно хотя бы раз попробовать реализовать модалку на dialog, чтобы понять всю их простоту.
На основе вашей статьи сделал небольшой интенсив, в котором нужно переписать модалку без использования JavaScript.
Будем вместе искоренять страх перед новыми возможностями)
oeditus
«Кодовый лифчик» — это прям почетное второе место в нейминге, сразу за «Блювотой» от Пепси.
codebra
Двенадцать лет назад я не проверил как будет переводиться сокращенная версия названия, а потом менять было поздно) Во всяком случае спасибо за почетное второе место.
Кстати, полное название изначально было CodeBrain :)