Custom Modal Dialog Component with Vanilla JavaScript
Create a reusable modal dialog with open/close functionality using only HTML, CSS, and JavaScript.
Code
<!--
Custom, reusable modal dialog (vanilla JS) with a11y: focus trap, ESC close, backdrop click.
Usage: add data-modal-open="modalId" to any trigger; include a [data-modal-close] button inside.
-->
<button type="button" data-modal-open="demo-modal">Open Modal</button>
<div id="demo-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title" aria-hidden="true">
<div class="modal__content" role="document" tabindex="-1">
<button class="modal__close" type="button" aria-label="Close dialog" data-modal-close>×</button>
<h2 id="modal-title">Modal Title</h2>
<p>This is a modal dialog.</p>
<button type="button" data-modal-close>Close</button>
</div>
</div>
<style>
.modal{display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.45);padding:clamp(16px,4vw,32px);overflow:auto}
.modal[aria-hidden="false"]{display:block}
.modal__content{background:#fff;margin:10vh auto;max-width:600px;border-radius:12px;padding:20px;border:1px solid rgba(0,0,0,.12);box-shadow:0 20px 60px rgba(0,0,0,.25);outline:0}
.modal__close{appearance:none;border:0;background:transparent;float:right;font-size:28px;line-height:1;color:#666;cursor:pointer}
.modal__close:hover,.modal__close:focus-visible{color:#000}
@media (prefers-reduced-motion:no-preference){.modal__content{animation:pop .14s ease-out}}
@keyframes pop{from{transform:translateY(6px);opacity:.7}to{transform:translateY(0);opacity:1}}
</style>
<script>
// Minimal modal controller: supports multiple modals; safe focus handling; cleans up listeners on close.
(() => {
const selectors = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
let activeModal = null, lastActiveEl = null, removeTrap = null;
const getFocusable = (root) => [...root.querySelectorAll(selectors)].filter(el => el.offsetParent !== null);
function openModal(modal) {
if (!modal || modal === activeModal) return;
if (activeModal) closeModal(activeModal);
activeModal = modal;
lastActiveEl = document.activeElement;
modal.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden'; // Prevent background scroll (performance + UX)
const panel = modal.querySelector('.modal__content') || modal;
const focusables = getFocusable(modal);
(focusables[0] || panel).focus({ preventScroll: true });
const onKeydown = (e) => {
if (e.key === 'Escape') return closeModal(modal);
if (e.key !== 'Tab') return;
const els = getFocusable(modal);
if (!els.length) return e.preventDefault();
const first = els[0], 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(); }
};
const onClick = (e) => {
if (e.target === modal || e.target.closest('[data-modal-close]')) closeModal(modal);
};
modal.addEventListener('keydown', onKeydown);
modal.addEventListener('click', onClick);
removeTrap = () => {
modal.removeEventListener('keydown', onKeydown);
modal.removeEventListener('click', onClick);
};
}
function closeModal(modal) {
if (!modal) return;
modal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
removeTrap?.(); removeTrap = null;
activeModal = null;
(lastActiveEl && lastActiveEl.focus) && lastActiveEl.focus({ preventScroll: true });
lastActiveEl = null;
}
document.addEventListener('click', (e) => {
const trigger = e.target.closest('[data-modal-open]');
if (!trigger) return;
const modalId = trigger.getAttribute('data-modal-open');
openModal(document.getElementById(modalId));
});
// Example usage: <button data-modal-open="demo-modal">Open</button>
})();
</script>