Create a Simple Modal Window from Scratch with Vanilla JavaScript
Build an accessible, reusable modal dialog using semantic HTML and a bit of vanilla JavaScript — focus trapping, ESC to close, labelled headings, and zero layout jank. We’ll keep it lean so it scores well on Core Web Vitals and reads clean in your codebase no matter how much you iterate.
If you like this approach, you’ll also like our pieces on progressive enhancement, accessibility basics for developers, and file structure for speed and scale. We build with the future in mind: minimal JS, maximum clarity.
Why Accessibility-First Modals Matter
Modals interrupt context. When they’re not labeled, keyboard-focusable, and dismissible, they quietly tank conversions and trust. Our target is a dialog that does three things well: announce itself, manage focus, and get out of the way. That’s not just UX; it’s search too. Accessible code tends to be structured, scannable, and linkable—the same traits that help pages rank and keep users engaged.
Reference points if you want to go deeper: the MDN dialog element, WAI-ARIA’s modal dialog pattern, and Chrome’s guidance on the inert attribute.
- Announces itself via a clear name/role (
<dialog>
orrole="dialog"
). - Traps focus inside while open and returns focus on close.
- Dismisses with Esc, a close button, or backdrop click.
- Prevents background interaction (
inert
/ aria-hiding) so assistive tech doesn’t read behind the curtain.
For brand consistency and conversion, see how this pattern complements our accessible contact form and the broader technical SEO for hand-coded sites.
Markup: Native <dialog> (with a Safe Fallback)
Preferring the native <dialog>
element with built-in semantics while using a backdrop is a relatively bulletproof approach to modal implementation. To be thorough, we will also include a fallback for older browsers. As is standard, we will name and describe the dialog with aria-labelledby
and aria-describedby
so screen readers announce the “what” and the “why" to site visitors who use assistive technologies.
<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>
Learn the fine points of naming and descriptions from W3C’s dialog role definition. For broader UI clarity, pair this with our guide to clean markup.
Controller: Open/Close, Focus Trap, and Return Focus
One controller to rule them all. We do not need to use several control mechanisms for this, so one controller for all dialogs on the page is the plan. It uses showModal()
when available and emulates modality with a tiny fallback so we follow the best practice of progressive enhancement.
<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");
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;
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>
If you’re curious about browser support and nuance, MDN’s showModal() reference is a quick read. Want to see how we keep JS small in real projects? Start with scalable static sites and why performance impacts SEO.
CSS: Backdrop, Motion, and Focus Rings
Developers, even seasoned ones, often forget that motion is an accessory behavior, not a strict necessity to site functionality — so keep it subtle and when in doubt about site decisions: default to clarity. Large touch targets, visible focus rings, and consistent spacing will carry more weight than fancy easing any day of the week.
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;
}
For an accessibility-first performance lens, skim understanding Core Web Vitals and mobile performance best practices.
Reuse the Component: Multiple Triggers, One Controller
Any button with data-target="dialog-id"
opens that dialog. Keep multiple instances on a page without extra scripts. For forms inside modals:
- Disable the primary button while submitting to prevent duplicate requests.
- On success, close the dialog and show a toast or inline success state.
- Return focus to the trigger for a predictable keyboard journey.
This fits neatly alongside our patterns for accessible FAQs and responsive image grids.
QA Checklist (Keyboard, Screen Readers, Mobile)
- Opens from a button and returns focus to the trigger on close.
- Traps focus (Tab/Shift+Tab) between the first and last focusable elements.
- Esc closes; clicking the backdrop closes; close button has a clear
aria-label
. - Reads a clear name and description via
aria-labelledby
andaria-describedby
. - Background content is inert (not keyboard-focusable) while the dialog is open.
- No layout shift; dialog width is constrained on mobile to avoid overflow.
Additional reading: W3C’s guidance on keyboard accessibility and focus order.
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 so you need to 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.
References
Related Guides
Key Takeaways
- Native
<dialog>
+ tiny controller = accessible modals with minimal JS. - Always trap focus, support Esc, and return focus to the trigger.
- Make the background inert to avoid accidental interaction and screen reader confusion.
- Keep motion gentle and focus rings visible for a WCAG-friendly experience.