Create a Simple Modal Window from Scratch with Vanilla JavaScript

By · Updated

Build an accessible, reusable modal dialog using semantic HTML and a pinch of vanilla JS—focus trapping, ESC to close, labelled headings, and zero layout jank.

Why Accessibility-First Modals Matter

Modals interrupt context. If they’re not labelled, keyboard-focusable, and dismissible, they break experiences (and conversions). We’ll ship a dialog that:

  • Announces itself via name/role (role="dialog" or native <dialog>).
  • Traps focus inside while open and returns focus on close.
  • Closes with Esc, a close button, or backdrop click.
  • Prevents background interaction (inert / aria-hiding) to avoid screen reader confusion.

Markup: Native <dialog> (with a Safe Fallback)

Prefer the native <dialog> element for built-in semantics and a backdrop. We’ll also include a tiny fallback for older browsers.

<button class="btn-open" data-target="demo-dialog">Open modal</button>

<dialog id="demo-dialog" aria-labelledby="demo-title" aria-describedby="demo-desc"
        class="p-0 rounded-xl max-w-lg w-[92vw] shadow-xl backdrop:bg-black/60">
  <header class="flex items-center justify-between px-4 py-3 border-b">
    <h2 id="demo-title" class="text-lg font-semibold">Subscribe to updates</h2>
    <button type="button" class="p-2" data-close aria-label="Close dialog">✕</button>
  </header>

  <div class="p-4 space-y-3">
    <p id="demo-desc">Get product news and articles (1–2 emails per month).</p>
    <label class="block">
      <span class="text-sm font-medium">Email</span>
      <input type="email" class="mt-1 w-full rounded border px-3 py-2" required>
    </label>
  </div>

  <footer class="flex gap-2 justify-end px-4 py-3 border-t">
    <button type="button" class="px-4 py-2 rounded border" data-close>Cancel</button>
    <button class="px-4 py-2 rounded bg-[#284B63] text-white">Subscribe</button>
  </footer>
</dialog>

The aria-labelledby/aria-describedby pair provides a clear name and description for assistive tech. The native backdrop is styled via .backdrop: utility or ::backdrop in plain CSS.

Controller: Open/Close, Focus Trap, and Return Focus

This controller handles all dialogs on the page. It uses showModal() when available and falls back to a div-based overlay if not.

<script>
(function(){
  const active = { dialog: null, previouslyFocused: null };
  const root = document.querySelector("main") || document.body; // non-modal content

  function openDialog(dlg){
    if (active.dialog) closeDialog(active.dialog);
    active.previouslyFocused = document.activeElement;

    // Fallback: if <dialog> unsupported, emulate modal behavior
    if (!dlg.showModal) {
      dlg.setAttribute("role", "dialog");
      dlg.setAttribute("aria-modal", "true");
      dlg.classList.add("fixed","inset-0","m-auto","bg-white"); // example styles
      dlg.style.display = "block";
      document.documentElement.style.overflow = "hidden";
    } else {
      dlg.showModal();
    }

    // Inert background for screen readers / keyboard
    root.setAttribute("inert", "");
    active.dialog = dlg;

    // Focus first focusable
    const focusables = dlg.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    (focusables[0] || dlg).focus();
  }

  function closeDialog(dlg){
    if (!dlg) return;
    root.removeAttribute("inert");
    if (dlg.close) dlg.close();
    dlg.style.display = "";
    document.documentElement.style.overflow = "";
    active.dialog = null;
    // Return focus
    active.previouslyFocused?.focus?.();
  }

  // Global handlers
  document.addEventListener("click", (e) => {
    const openBtn = e.target.closest("[data-target]");
    if (openBtn) {
      const dlg = document.getElementById(openBtn.dataset.target);
      if (dlg) openDialog(dlg);
    }
    if (e.target.closest("[data-close]")) {
      const dlg = e.target.closest("dialog,[role='dialog']");
      closeDialog(dlg);
    }
  });

  // Backdrop click (native <dialog>)
  document.addEventListener("click", (e) => {
    const dlg = active.dialog;
    if (!dlg) return;
    const rect = dlg.getBoundingClientRect();
    const clickedOutside =
      e.clientX < rect.left || e.clientX > rect.right ||
      e.clientY < rect.top  || e.clientY > rect.bottom;
    if (clickedOutside && e.target === dlg) closeDialog(dlg);
  });

  // ESC to close + focus trap
  document.addEventListener("keydown", (e) => {
    const dlg = active.dialog; if (!dlg) return;
    if (e.key === "Escape") { e.preventDefault(); closeDialog(dlg); return; }
    if (e.key === "Tab") {
      const f = dlg.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])');
      const first = f[0], last = f[f.length - 1];
      if (!first || !last) return;
      if (e.shiftKey && document.activeElement === first) { last.focus(); e.preventDefault(); }
      else if (!e.shiftKey && document.activeElement === last) { first.focus(); e.preventDefault(); }
    }
  });
})();
</script>

We use the inert attribute to disable interaction with background content; if you support very old browsers, you can emulate it with an aria-hidden strategy.

CSS: Backdrop, Motion, and Focus Rings

Keep visuals crisp and motion subtle, with accessible focus styles.

dialog::backdrop { background: rgba(0,0,0,.6); }
dialog[open] { animation: scaleIn .14s ease-out; }
@keyframes scaleIn { from { opacity:0; transform: translateY(4px) scale(.98); }
                     to   { opacity:1; transform: translateY(0)   scale(1); } }
:where(button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])):focus {
  outline: 2px solid #d4a856; outline-offset: 2px;
}

Reuse the Component: Multiple Triggers, One Controller

Any button with data-target="dialog-id" opens that dialog. You can keep multiple dialog instances on a page with the same controller. For forms inside modals:

  • Prevent form submit spam by disabling the primary button while submitting.
  • On success, close the dialog and show a toast or inline success state.
  • Return focus to the trigger for a predictable keyboard journey.

QA Checklist (Keyboard, Screen Readers, Mobile)

  • ✅ Opens via button and returns focus to the trigger on close.
  • ✅ Traps focus (Tab/Shift+Tab) between first and last focusable elements.
  • ✅ Esc closes; clicking backdrop closes; close button has aria-label.
  • ✅ Reads a clear name/description via aria-labelledby/aria-describedby.
  • ✅ Background content is inert (not keyboard-focusable) while open.
  • ✅ No layout shift; dialog size is constrained on mobile.

FAQs

Should I use <dialog> or a div with role="dialog"?

Prefer <dialog> for built-in semantics and backdrop. Use a div fallback only for legacy support.

Do I need a focus trap if I use <dialog>?

<dialog> helps with modality, but it doesn’t trap focus for you—implement the small Tab/Shift+Tab handler above.

How do I prevent background scroll on iOS?

When the fallback is active, set document.documentElement.style.overflow = "hidden" and restore it on close.

Key Takeaways

  • Native <dialog> + tiny controller = accessible modals with minimal JS.
  • Always trap focus, support Esc, and return focus to the trigger.
  • Make background inert to avoid accidental interaction and SR confusion.
  • Keep motion gentle and focus rings visible for WCAG-friendly UX.
Disclaimer: This tutorial is provided for educational and informational purposes only and does not constitute legal, financial, or professional advice. All content is offered “as-is” without warranties of any kind. Readers are solely responsible for implementation and must ensure compliance with applicable laws, accessibility standards, and platform terms. Always apply the information only within authorized, ethical, and legal contexts.

Spot an error or a better angle? Tell me and I’ll update the piece. I’ll credit you by name—or keep it anonymous if you prefer. Accuracy > ego.

Portrait of Mason Goulding

Mason Goulding · Founder, Maelstrom Web Services

Builder of fast, hand-coded static sites with SEO baked in. Stack: Eleventy · Vanilla JS · Netlify · Figma

With 10 years of writing expertise and currently pursuing advanced studies in computer science and mathematics, Mason blends human behavior insights with technical execution. His Master’s research at CSU–Sacramento examined how COVID-19 shaped social interactions in academic spaces — see his thesis on Relational Interactions in Digital Spaces During the COVID-19 Pandemic . He applies his unique background and skills to create successful builds for California SMBs.

Every build follows Google’s E-E-A-T standards: scalable, accessible, and future-proof.