Basic Modal
A titled dialog with a focus trap, Escape to close and focus returned to the trigger.
6 frameworksIntermediate
A destructive confirmation that opens with focus on Cancel, not on Delete.
<!--
A confirm is an alertdialog, not a dialog: the difference tells a screen
reader this interrupted the user with something consequential rather than
merely opening a panel. aria-describedby points at the sentence that says what
will be destroyed, so the dialog announces its name AND its stakes.
Note which button gets initial focus. The trap opens on Cancel - the SAFE
action - because a modal that steals focus and lands it on "Delete" turns a
reflexive Enter into data loss. Autofocus goes to the way out, never the
irreversible thing. Red is not a safeguard; it means nothing to a screen
reader and little to a red-blind user. Focus order is the safeguard.
-->
<button class="cmodal-trigger" type="button" data-cmodal-open>Delete workspace</button>
<div class="cmodal" id="confirm-modal" hidden>
<div class="cmodal__backdrop" data-cmodal-close></div>
<div
class="cmodal__panel"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
aria-describedby="confirm-modal-desc"
>
<div class="cmodal__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
<path d="M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
</svg>
</div>
<h2 class="cmodal__title" id="confirm-modal-title">Delete workspace?</h2>
<p class="cmodal__desc" id="confirm-modal-desc">
This deletes 428 projects and removes 12 members. It cannot be undone.
</p>
<div class="cmodal__actions">
<button class="cmodal__btn" type="button" data-cmodal-cancel data-cmodal-close>Cancel</button>
<button class="cmodal__btn cmodal__btn--danger" type="button" data-cmodal-confirm>Delete</button>
</div>
</div>
</div>
<script>
(function () {
var FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
var root = document.getElementById('confirm-modal');
var trigger = document.querySelector('[data-cmodal-open]');
var panel = root.querySelector('.cmodal__panel');
var cancel = root.querySelector('[data-cmodal-cancel]');
var confirm = root.querySelector('[data-cmodal-confirm]');
var restore = null;
function focusables() {
return Array.prototype.slice.call(panel.querySelectorAll(FOCUSABLE));
}
function open() {
restore = document.activeElement;
root.hidden = false;
document.body.style.overflow = 'hidden';
// Explicitly Cancel - NOT focusables()[0], which would drift to whatever
// markup happens to come first the next time this panel is edited.
cancel.focus();
}
function close() {
root.hidden = true;
document.body.style.overflow = '';
if (restore && typeof restore.focus === 'function') restore.focus();
}
trigger.addEventListener('click', open);
Array.prototype.forEach.call(root.querySelectorAll('[data-cmodal-close]'), function (el) {
el.addEventListener('click', close);
});
confirm.addEventListener('click', function () {
// Do the destructive work here, then close.
close();
});
root.addEventListener('keydown', function (event) {
// Escape is a cancel, never a confirm. An overlay's dismiss gesture must
// always resolve to the harmless outcome.
if (event.key === 'Escape') {
close();
return;
}
if (event.key !== 'Tab') return;
var items = focusables();
if (!items.length) return;
var first = items[0];
var last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
});
})();
</script>| Prop | Type | Default | Description |
|---|---|---|---|
openrequired | boolean | - | Whether the component is currently shown. |
titlerequired | string | - | Heading text for the card. |
messagerequired | string | - | Body text of the notification. |
ctaLabel | string | 'Delete' | Call-to-action button text. |
dismissLabel | string | 'Cancel' | Accessible name for the dismiss button. |
onConfirmrequired | () => void | - | Fired once the destructive action is confirmed. |
onDismissrequired | () => void | - | Fired when the user dismisses the notification. |
Two decisions carry this component. It is `role="alertdialog"`, not `dialog` - that is what says "this interrupted you with something consequential" - and `aria-describedby` points at the sentence naming what will be destroyed, so it announces its stakes and not just its title. Then: initial focus goes to Cancel, by ref, explicitly. Not `focusables()[0]`, which drifts the day someone reorders the markup. A dialog that steals focus onto "Delete" turns a reflexive Enter into data loss. Red is not the safeguard - it is invisible to a red-blind user and means nothing to a screen reader; focus order and the confirm step are the safeguard. Escape always resolves to Cancel.