Reusable Confirmation Modal with Vanilla JavaScript
Create a reusable confirmation modal dialog for user actions, enhancing interactivity and UX.
Code
/**
* Reusable, accessible confirmation modal (vanilla JS).
* Use confirmModal("Message") to await the user's choice; resolves true/false.
*/
(() => {
const injectStylesOnce = (() => {
let injected = false;
return () => {
if (injected) return;
injected = true;
const style = document.createElement("style");
style.textContent = `
.cm-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:9999}
.cm-dialog{background:#fff;color:#111;max-width:min(92vw,420px);width:100%;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.25);padding:18px}
.cm-title{margin:0 0 10px;font:600 16px/1.3 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif}
.cm-message{margin:0 0 16px;font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#333}
.cm-actions{display:flex;gap:10px;justify-content:flex-end}
.cm-btn{appearance:none;border:1px solid #d0d7de;background:#fff;color:#111;border-radius:10px;padding:8px 12px;font:600 14px system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;cursor:pointer}
.cm-btn:focus{outline:3px solid rgba(0,120,255,.35);outline-offset:2px}
.cm-btn--danger{border-color:#e34d4d;background:#e34d4d;color:#fff}
.cm-btn--danger:hover{filter:brightness(.95)}
.cm-btn[disabled]{opacity:.6;cursor:not-allowed}
`;
document.head.appendChild(style);
};
})();
const getFocusable = (root) =>
[...root.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])')]
.filter((el) => !el.hasAttribute("disabled") && !el.getAttribute("aria-hidden"));
const confirmModal = (message, options = {}) => {
injectStylesOnce();
const {
title = "Please confirm",
confirmText = "Confirm",
cancelText = "Cancel",
danger = false,
} = options;
return new Promise((resolve, reject) => {
try {
if (typeof message !== "string" || !message.trim()) throw new TypeError("Modal message must be a non-empty string.");
// Build DOM with text nodes (avoids innerHTML injection).
const backdrop = document.createElement("div");
backdrop.className = "cm-backdrop";
backdrop.setAttribute("role", "presentation");
const dialog = document.createElement("div");
dialog.className = "cm-dialog";
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
const h2 = document.createElement("h2");
h2.className = "cm-title";
h2.textContent = title;
const p = document.createElement("p");
p.className = "cm-message";
p.textContent = message;
const actions = document.createElement("div");
actions.className = "cm-actions";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "cm-btn";
cancelBtn.textContent = cancelText;
const confirmBtn = document.createElement("button");
confirmBtn.type = "button";
confirmBtn.className = `cm-btn${danger ? " cm-btn--danger" : ""}`;
confirmBtn.textContent = confirmText;
actions.append(cancelBtn, confirmBtn);
dialog.append(h2, p, actions);
backdrop.append(dialog);
document.body.appendChild(backdrop);
// Trap focus for accessibility; minimal listeners for performance.
const previousActive = document.activeElement;
const focusables = () => getFocusable(dialog);
const cleanup = () => {
document.removeEventListener("keydown", onKeydown, true);
backdrop.removeEventListener("click", onBackdropClick);
confirmBtn.removeEventListener("click", onConfirm);
cancelBtn.removeEventListener("click", onCancel);
backdrop.remove();
previousActive?.focus?.();
};
const finish = (result) => {
cleanup();
resolve(result);
};
const onConfirm = async () => {
// Support async callers by letting them await confirmModal, not via callback.
confirmBtn.disabled = true; // prevent double submits
finish(true);
};
const onCancel = () => finish(false);
const onBackdropClick = (e) => {
if (e.target === backdrop) finish(false);
};
const onKeydown = (e) => {
if (e.key === "Escape") return finish(false);
if (e.key !== "Tab") return;
const els = focusables();
if (!els.length) return;
const first = els[0];
const last = els[els.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
};
confirmBtn.addEventListener("click", onConfirm);
cancelBtn.addEventListener("click", onCancel);
backdrop.addEventListener("click", onBackdropClick);
document.addEventListener("keydown", onKeydown, true);
// Defer focus to ensure DOM is painted; improves reliability across browsers.
queueMicrotask(() => (confirmBtn.focus()));
} catch (err) {
reject(err);
}
});
};
// Export to the global scope in a single, non-invasive namespace.
window.UI = Object.freeze({ ...(window.UI || {}), confirmModal });
// Example usage:
// document.querySelector("#delete-button")?.addEventListener("click", async () => {
// try {
// const ok = await window.UI.confirmModal("Are you sure you want to delete this item?", { danger: true });
// if (ok) console.log("Item deleted!");
// } catch (e) { console.error("Modal error:", e); }
// });
})();