Dialogs
Summary
Dialog2 is the modal, built on the native
<dialog> element: the platform provides the
top layer, the ::backdrop scrim, focus containment
(the page behind is inert), focus restore and Esc handling;
Soma adds the look, the events and the strict-modal switch.
Four widths. Purely imperative: a dialog never opens on its
own; your code calls .show(). The accessible name
is auto-derived from the header heading, so every dialog
following the markup contract is announced by its title. Its
non-modal slide-over sibling for detail views is the
Drawer.
When to use
| Pattern | Use it for |
|---|---|
Dialog -small | Confirmations — one question, two buttons. |
Soma.confirm() / Soma.alert() | The everyday confirmation/acknowledgement without authoring any markup; see Confirm and alert shorthands below. |
Dialog -medium+ | Focused create/edit forms that must complete before returning. |
Dialog -large / -xlarge | Reviewing tabular or side-by-side content before acting: pickers, comparisons. |
data-soma-modal="true" | Strict must-act states (session expired) — Esc and backdrop clicks do nothing. |
| Drawer | Inspecting one item from a collection while the collection stays visible and clickable. |
Examples
Keyboard & focus
| Key | Action |
|---|---|
| Tab / Shift+Tab | Move through the dialog's focusable elements. Containment is native: showModal() makes the rest of the page inert, so focus cannot leave the dialog — no JS focus trap involved. |
| Esc | Close the topmost open dialog (the platform's close request, surfaced as the cancel event), unless the dialog is strict (data-soma-modal="true"), which suppresses it. |
Initial focus follows the platform: the first focusable element
inside the dialog, or the element carrying an
autofocus attribute if you set one. Put
autofocus on the field the user came to fill in
(a dialog with no focusable content is focused itself, so screen
readers still land in it). On close (however it was triggered)
the platform returns focus to the element that was focused when
the dialog opened. Clicking the backdrop closes a non-strict
dialog.
Top layer behaviour
An open dialog lives in the browser's top layer —
above everything in the page, regardless of any
z-index. Each .show() places the
dialog above previously opened ones with its own
::backdrop, and Esc is routed by the platform to
the topmost dialog only, so stacked dialogs unwind one at a
time. The same rule covers overlays opened above the
dialog: an Esc that finds an open dropdown menu or inline
dialog closes that popover only, in every engine: the dialog
ignores a cancel fired on the same keystroke
(WebKit, which has no close-watcher stack, fires both at
once). The document behind the dialog is inert: it
cannot be clicked, focused, or reached by assistive technology
while the dialog is open. Backdrop clicks close a non-strict
dialog via the native closedby="any" light dismiss
where the engine ships it; Soma covers the remaining engines
(Safari) with a small fallback listener until Interop 2026
completes. Toasts also enter the top
layer, so they stay visible above an open dialog.
Declarative triggers (Invoker Commands)
Because a dialog2 is a native <dialog>,
the platform's Invoker Commands API (Baseline newly available,
late 2025) works on it with zero JavaScript: a button pointing at
the dialog with commandfor opens it modally with
command="show-modal" (same top layer, backdrop and
focus behaviour as .show()) and
command="close" closes it:
<button class="soma-button" commandfor="dlg-invoker" command="show-modal">
Open (declarative)</button>
<dialog class="soma-dialog2 soma-dialog2-small" id="dlg-invoker" closedby="any">
…
<button class="soma-button" commandfor="dlg-invoker" command="close">
Close (declarative)</button>
</dialog>
.show() remains the API for programmatic opening;
declarative triggers are for the plain "this button opens that
dialog" case. Still call Soma.dialog2('#dlg-invoker')
once at load: binding derives the accessible name, wires the
header close button, and attaches the native close
listener, so soma-dialog-hide (and
.on('hide')) fires on every close path,
invoker-closed included, as the live counter above shows. One
honest limitation: soma-dialog-show is
dispatched only by .show() — the component
does not observe native opening, so an invoker-opened dialog
fires no show event. If you need an open signal from declarative
triggers, listen for the dialog's native toggle
event (e.newState === 'open'), which engines that
ship Invoker Commands also ship. Two smaller notes:
data-soma-modal="true" strictness still holds
(the Esc veto is bound at init, not at show time), and backdrop
light dismiss is normally arranged by .show(). For
a dialog that may first open declaratively, author
closedby="any" in the markup yourself, as above.
Confirm and alert shorthands
For the everyday "one question, two buttons" case you don't have
to author dialog markup at all: Soma.confirm() and
Soma.alert() build a small dialog2 (a native
<dialog>), show it through the normal
machinery (top layer, backdrop, native focus handling, derived
accessible name), and remove it from the DOM after it closes.
Both return a promise: confirm() resolves
true on the confirm button and false on
any other way out (cancel button, Esc, backdrop click);
alert() resolves once dismissed. Default button
labels are localised (dialog.confirm /
dialog.cancel / dialog.ok in the
i18n catalog), so they follow the active
locale.
Soma.confirm(options)
| Option | Description |
|---|---|
title | Dialog title (plain text). Required; it becomes the accessible name. |
body | Optional plain-text body paragraph. |
appearance | 'primary' (default) or 'danger', which tints the confirm button for destructive commands. Anything else throws. |
confirmLabel / cancelLabel | Button labels; default to the localised "Confirm" / "Cancel". |
onConfirm / onCancel | Optional callbacks, fired alongside the promise settling. onCancel covers the cancel button, Esc, and backdrop clicks alike. |
Soma.alert(options)
| Option | Description |
|---|---|
title | Dialog title (plain text). Required. |
body | Optional plain-text body paragraph. |
okLabel | Button label; defaults to the localised "OK". |
The promise style reads best inside async flows:
if (await Soma.confirm({
title: 'Delete deployment?',
body: 'This stops soma-cdn and removes its 8 instances.',
appearance: 'danger',
confirmLabel: 'Delete',
})) {
await api.deleteDeployment(id);
}
await Soma.alert({ title: 'Export finished', body: '128 rows written.' });
Or use the callbacks; both styles work, on the same call:
Soma.confirm({
title: 'Apply changes?',
onConfirm: () => applyChanges(),
onCancel: () => console.log('kept as-is'),
});
HTML
The full markup contract: the element must be a native
<dialog> (the factory throws
otherwise). Place dialogs at the end of
<body>, outside the page layout. No
aria-hidden, no role, no
aria-modal: a closed <dialog> is
hidden by the platform, and the native open
attribute is the state signal:
<dialog class="soma-dialog2 soma-dialog2-small" id="confirm">
<header class="soma-dialog2-header">
<h2 class="soma-dialog2-header-main">Delete?</h2>
<button class="soma-dialog2-header-close" aria-label="Close">
<span class="soma-icon soma-icon-close"></span>
</button>
</header>
<div class="soma-dialog2-content">…</div>
<footer class="soma-dialog2-footer">
<div class="soma-dialog2-footer-hint">Optional start-aligned hint.</div>
<div class="soma-dialog2-footer-actions">
<button class="soma-button soma-button-danger">Delete</button>
<button class="soma-button soma-button-link">Cancel</button>
</div>
</footer>
</dialog>
Pick a width with the size modifier; -medium
(600px) is the default when no modifier is present:
<dialog class="soma-dialog2 soma-dialog2-small">…</dialog> <!-- 400px -->
<dialog class="soma-dialog2 soma-dialog2-medium">…</dialog> <!-- 600px -->
<dialog class="soma-dialog2 soma-dialog2-large">…</dialog> <!-- 800px -->
<dialog class="soma-dialog2 soma-dialog2-xlarge">…</dialog> <!-- 968px -->
A strict modal adds data-soma-modal="true": Esc
and backdrop clicks do nothing, so it usually has no header close
button either; only your own controls close it:
<dialog class="soma-dialog2 soma-dialog2-small" id="expired"
data-soma-modal="true">
<header class="soma-dialog2-header">
<h2 class="soma-dialog2-header-main">Session expired</h2>
</header>
<div class="soma-dialog2-content"><p>Please sign in again.</p></div>
<footer class="soma-dialog2-footer">
<div class="soma-dialog2-footer-actions">
<button class="soma-button soma-button-primary" id="relogin">Sign in</button>
</div>
</footer>
</dialog>
The native element carries the dialog role and modality itself,
so binding only derives the accessible name. When the element
has no aria-label/aria-labelledby of
its own, the component points aria-labelledby at
the .soma-dialog2-header-main heading (assigning it
an id if needed; any h1–h3 inside the
header works as a fallback). An explicit attribute on the
element always wins. To choose where focus lands on open, put
autofocus on the element that should receive it.
CSS classes
| Class | Effect |
|---|---|
.soma-dialog2 | The modal card, on a native <dialog>. Hidden by the platform until open; capped at the viewport (max-height/max-width), so long content scrolls inside. |
-small / -medium / -large / -xlarge | Widths: 400 / 600 / 800 / 968px. No modifier = 600px. |
.soma-dialog2-header / -header-main / -header-close | Header band: the h2 heading + dismiss button. Any descendant .soma-dialog2-header-close closes on click. |
.soma-dialog2-content | The scrolling body band. |
.soma-dialog2-footer / -footer-hint / -footer-actions | Footer band; the hint sits at the start, actions at the end. |
[data-soma-modal="true"] | Strict modal: Esc and backdrop clicks are ignored. |
[open] | The native open-state attribute, managed by the platform. It is the styling hook for the visible dialog (Soma's enter transition lives on it). Never write it yourself; call .show(). |
::backdrop | The scrim behind the dialog: a platform pseudo-element in the top layer (there is no blanket element to author or query). Tinted via --soma-surface-backdrop. |
JavaScript
Constructor
| Member | Description |
|---|---|
Soma.dialog2(elOrSelector) | Get or create the singleton instance for the element. Throws when nothing matches, when the element lacks the soma-dialog2 class, or when it is not a native <dialog>. Binding wires the close button and derives the accessible name (see HTML). Call it once at load so the derived name is in place before the first open. |
Instance methods
| Member | Description |
|---|---|
.show() | Open via showModal(): the dialog enters the top layer with its ::backdrop, the page becomes inert, the platform moves focus in (first focusable, or your autofocus), and show is dispatched. No-op when already open. |
.hide() | Close via the native close(): the platform restores focus to the opener; hide is dispatched from the native close event, so it also fires when the dialog closes without .hide() (Esc, backdrop click, a method="dialog" form). No-op when already closed. |
.toggle() | show() or hide() depending on state. |
.remove() | Hide, drop the singleton, and remove the element from the DOM: teardown for dialogs built at runtime. |
.destroy() | Unbind and restore the pre-init markup: listeners removed, the derived aria-labelledby (and any heading id the component minted) stripped; author-supplied attributes stay, and the element stays in the DOM. Closes the dialog first if open (the final hide still fires). For fragments about to be re-rendered; use .remove() to delete the element too. |
.on(event, fn) / .off(event, fn) | Subscribe / unsubscribe: 'show' and 'hide'. |
.isOpen | Boolean state, readable at any time. |
All methods return the instance, so calls chain. The underlying
DOM events are bubbling CustomEvents
(soma-dialog-show / soma-dialog-hide)
dispatched on the dialog element, so
addEventListener on the document works for
delegated listening. soma-dialog-hide follows the
platform's close event, which browsers fire from a
queued task — expect it a moment after .hide()
returns, from every close path alike.
Listen on the instance, and unsubscribe with the same function reference:
const dlg = Soma.dialog2('#confirm');
const onShow = () => console.log('opened');
dlg.on('show', onShow);
dlg.off('show', onShow); // same fn reference removes it
Or listen delegated; the CustomEvents bubble, so one document listener sees every dialog:
document.addEventListener('soma-dialog-show', (e) => {
console.log('dialog opened:', e.target.id);
});
Programmatic control — everything chains:
Soma.dialog2('#confirm').show(); // top layer, backdrop, focus moves in
Soma.dialog2('#confirm').hide(); // focus returns to the opener
Soma.dialog2('#confirm').toggle();
Soma.dialog2('#confirm').on('hide', refreshList).show();
if (Soma.dialog2('#confirm').isOpen) { /* … */ }
Build a dialog at runtime, bind it, and tear it down with
.remove() when done:
document.body.insertAdjacentHTML('beforeend', renderedDialogMarkup);
const dlg = Soma.dialog2('#one-shot');
dlg.on('hide', () => dlg.remove()); // hide + drop instance + remove DOM
dlg.show();
Strict modal — the markup carries
data-soma-modal="true"; only your own controls
close it:
const expired = Soma.dialog2('#expired');
document.getElementById('relogin')
.addEventListener('click', () => expired.hide());
The live examples above, exactly as this page wires them:
// Init at load so the auto-derived aria-labelledby is in place up front.
const confirm = Soma.dialog2('#dlg-confirm');
const form = Soma.dialog2('#dlg-form');
const large = Soma.dialog2('#dlg-large');
const xlarge = Soma.dialog2('#dlg-xlarge');
const strict = Soma.dialog2('#dlg-strict');
const stack = Soma.dialog2('#dlg-stack');
document.getElementById('open-confirm').addEventListener('click', () => confirm.show());
document.getElementById('open-form').addEventListener('click', () => form.show());
document.getElementById('open-large').addEventListener('click', () => large.show());
document.getElementById('open-xlarge').addEventListener('click', () => xlarge.show());
document.getElementById('open-strict').addEventListener('click', () => strict.show());
document.getElementById('open-stack').addEventListener('click', () => stack.show());
document.getElementById('open-events').addEventListener('click', () => confirm.show());
document.getElementById('confirm-yes').addEventListener('click', () => confirm.hide());
document.getElementById('confirm-no').addEventListener('click', () => confirm.hide());
document.getElementById('form-cancel').addEventListener('click', () => form.hide());
document.getElementById('large-close').addEventListener('click', () => large.hide());
document.getElementById('xlarge-close').addEventListener('click', () => xlarge.hide());
document.getElementById('strict-ok').addEventListener('click', () => strict.hide());
// Stacked: open the confirm on top of the edit dialog.
document.getElementById('stack-delete').addEventListener('click', () => confirm.show());
document.getElementById('stack-close').addEventListener('click', () => stack.hide());
const out = document.getElementById('dlg-events-out');
confirm.on('show', () => { out.textContent = 'soma-dialog-show'; });
confirm.on('hide', () => { out.textContent = 'soma-dialog-hide'; });
// Invoker Commands demo: no click handlers — the buttons are pure
// markup. Binding at load (above the fold: Soma.dialog2 derives the
// accessible name and attaches the native close listener) is what
// makes 'hide' fire on invoker-closed dialogs. Note there is NO
// 'show' counterpart here: soma-dialog-show only fires via .show().
const invoker = Soma.dialog2('#dlg-invoker');
let invokerHides = 0;
invoker.on('hide', () => {
document.getElementById('invoker-out').textContent = String(++invokerHides);
});
// Shorthands — no markup anywhere on this page for these three
const scOut = document.getElementById('shorthand-out');
document.getElementById('open-shorthand-confirm').addEventListener('click', async () => {
const ok = await Soma.confirm({
title: 'Apply changes?',
body: 'The service restarts within a minute.',
});
scOut.textContent = `resolved ${ok}`;
});
document.getElementById('open-shorthand-danger').addEventListener('click', () => {
Soma.confirm({
title: 'Delete deployment?',
body: 'This stops soma-cdn and removes its 8 instances.',
appearance: 'danger',
confirmLabel: 'Delete',
onConfirm: () => { scOut.textContent = 'onConfirm fired'; },
onCancel: () => { scOut.textContent = 'onCancel fired'; },
});
});
document.getElementById('open-shorthand-alert').addEventListener('click', async () => {
await Soma.alert({
title: 'Export finished',
body: '128 rows written to soma-export.csv.',
});
scOut.textContent = 'alert resolved';
});