Create a Simple Modal Window from Scratch with Vanilla JavaScript
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.