Confirm Modal
A destructive confirmation that opens with focus on Cancel, not on Delete.
6 frameworksAdvanced
A titled dialog with a focus trap, Escape to close and focus returned to the trigger.
<!--
The panel - not the backdrop - carries the dialog semantics. aria-modal="true"
is what tells a screen reader the rest of the document is inert; it does NOT
implement that, which is why the script below still has to trap Tab. The two
together are the component. Either alone is a half-measure.
aria-labelledby points at the visible <h2>, so the dialog announces with the
same name a sighted user reads. Do not duplicate it into an aria-label.
-->
<button class="modal-trigger" type="button" data-modal-open="basic-modal">
Share project
</button>
<div class="modal" id="basic-modal" hidden>
<div class="modal__backdrop" data-modal-close></div>
<div class="modal__panel" role="dialog" aria-modal="true" aria-labelledby="basic-modal-title">
<div class="modal__head">
<h2 class="modal__title" id="basic-modal-title">Share project</h2>
<button class="modal__close" type="button" aria-label="Close" data-modal-close>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
<p class="modal__body">
Anyone with the link can view this project. Members of your workspace keep
the access they already have.
</p>
<div class="modal__actions">
<button class="modal__btn modal__btn--primary" type="button" data-modal-close>Done</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('basic-modal');
var trigger = document.querySelector('[data-modal-open="basic-modal"]');
var panel = root.querySelector('.modal__panel');
var restore = null;
function focusables() {
return Array.prototype.slice.call(panel.querySelectorAll(FOCUSABLE));
}
function open() {
// Remember who summoned us before moving focus, not after.
restore = document.activeElement;
root.hidden = false;
// Scrolling the page behind a modal detaches the backdrop from the thing
// it is meant to be blocking. Lock it.
document.body.style.overflow = 'hidden';
var first = focusables()[0];
if (first) first.focus();
}
function close() {
root.hidden = true;
document.body.style.overflow = '';
// Without this the keyboard user is dumped at the top of the document
// with no idea where they are. Restoring focus is not a nicety.
if (restore && typeof restore.focus === 'function') restore.focus();
}
trigger.addEventListener('click', open);
Array.prototype.forEach.call(root.querySelectorAll('[data-modal-close]'), function (el) {
el.addEventListener('click', close);
});
root.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
close();
return;
}
if (event.key !== 'Tab') return;
// The trap. Tab off the last control wraps to the first, Shift+Tab off
// the first wraps to the last. Without it, focus walks straight out into
// the page this dialog claims to be blocking.
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. |
childrenrequired | ReactNode | - | Content rendered inside the component. |
dismissLabel | string | 'Close' | Accessible name for the dismiss button. |
onDismissrequired | () => void | - | Fired when the user dismisses the notification. |
className | string | - | Additional classes merged onto the root element. |
The box is the easy part; the seven lines that make it a dialog are the component. `role="dialog"` + `aria-modal="true"` tells a screen reader the page behind is inert - it does not MAKE it inert, which is why the Tab handler still has to wrap focus by hand. Escape closes, a backdrop click closes, `body` scroll is locked while it is up, and the effect cleanup restores focus to whatever opened it, so a dialog torn down by a route change still hands the keyboard back. Change the copy and the `max-w-md`; leave the `FOCUSABLE` selector alone unless you are adding a control type it does not list.