Skip to content

Behaviors — Dialog

and-dialog-trigger="target-id" opens whatever element has that id as a modal. The target can be a <template> (cloned fresh on every open — no need to hide it in the page beforehand) or a real, already-visible element (reparented into the dialog and moved back on close):

Open settings
<button and-dialog-trigger="settings-panel">Open settings</button>
<template
id="settings-panel"
and-dialog-position="right"
and-dialog-width="20rem"
>
<div>
<strong>Settings</strong>
<button and-dialog-close>Close</button>
</div>
</template>

If your dialog’s content is already live in the page rather than a <template>, hide it with class="hidden" (a Tailwind-style utility, not style="display: none") — createDialog specifically detects and clears that class when it moves the element in, and re-adds it when the element moves back on close. An inline style="display: none" is only cleared on open, never restored on close, so the element would be left visible in its original spot in the page once the dialog is dismissed.

AttributeWhereValuesDefault
and-dialog-triggertriggerstring (target element id)required
and-dialog-positiontrigger and/or target'center'|'top'|'bottom'|'left'|'right''center'
and-dialog-backdroptrigger and/or target'false' to disableshown
and-dialog-close-on-backdroptrigger and/or target'false' to disableenabled
and-dialog-close-on-escapetrigger and/or target'false' to disableenabled
and-dialog-width / -heighttrigger and/or targetany CSS length
and-dialog-panel-classtrigger and/or targetclass name(s)
and-dialog-backdrop-classtrigger and/or targetclass name(s)
and-dialog-closeany descendant

Any attribute is read from the trigger first, then the target as a fallback — set it on whichever element is more convenient. Any element with and-dialog-close inside the opened content closes the dialog when clicked.

import { createDialog } from '@andersseen/behaviors/dialog';
const dialog = createDialog(document.getElementById('panel'), {
position: 'right',
});
dialog.closed.then(() => console.log('dialog dismissed'));
// dialog.close();

content accepts three shapes, each handled differently:

TypeBehavior
HTMLElementReparented into the dialog panel (a comment placeholder marks its original spot) and moved back on close — including restoring a hidden class if it had one.
HTMLTemplateElementCloned via importNode into the panel — the original template is left untouched, so you can open the same template more than once.
stringSet as panelEl.innerHTML directly.
OptionTypeDefault
position'center'|'top'|'bottom'|'left'|'right''center'
backdropbooleantrue
closeOnBackdropClickbooleantrue
closeOnEscapebooleantrue
width / heightstring (any CSS length)
panelClassstring | string[]
backdropClassstring | string[]

DialogRef is { close(): void; readonly closed: Promise<void> }closed resolves once, whenever the dialog is dismissed (backdrop click, Escape, a [and-dialog-close] element inside the content, or your own close() call — [and-dialog-close] auto-wiring works the same whether the dialog was opened declaratively or imperatively).

Dialogs stack: open a second one while the first is open and Escape only closes the top-most. Body scroll is locked (overflow: hidden) while at least one dialog is open, and restored only once the last one closes. Focus moves to the first focusable element in the panel on open, is trapped inside it on Tab/Shift+Tab, and returns to whatever had focus before createDialog was called once it closes.

Delete item…
<button id="trigger">Delete item…</button>
<template id="confirm-template">
<div>
<strong>Delete this item?</strong>
<button and-dialog-close>Cancel</button>
<button id="confirm-ok">Delete</button>
</div>
</template>
<script type="module">
import { createDialog } from '@andersseen/behaviors/dialog';
document.getElementById('trigger').addEventListener('click', () => {
const dialog = createDialog(document.getElementById('confirm-template'));
document
.getElementById('confirm-ok')
.addEventListener('click', () => dialog.close());
});
</script>

Sets role="dialog" and aria-modal="true" on the panel, traps Tab/Shift+Tab inside it (disabled/hidden controls skipped by the focusable-elements query), locks body scroll while open, and restores focus to whatever triggered the dialog once it closes.

Recipes → A promise-based confirm dialog