JavaScript 54 views

Reusable Confirmation Modal with Vanilla JavaScript

Create a reusable confirmation modal dialog for user actions, enhancing interactivity and UX.

By TWC Team • Jan 27, 2026

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); }
  // });
})();
Back to Snippets