Building Modal Dialogs with Vanilla JavaScript
A comprehensive guide to building accessible modal dialogs with vanilla JavaScript covering the dialog element, focus trapping, keyboard navigation, and reusable modal patterns.
Building Modal Dialogs with Vanilla JavaScript
Overview
Modal dialogs are everywhere in modern web applications. Login forms, confirmation prompts, image lightboxes, settings panels — they all share the same fundamental pattern: temporarily take over the user's attention, collect input or display information, then return control to the page behind them.
Most developers reach for a library to handle modals. That is usually overkill. The browser gives you a native <dialog> element that handles most of the hard work. When you need more control, building a custom modal from scratch is straightforward — as long as you get the accessibility and keyboard behavior right.
This article walks through everything you need to build production-quality modal dialogs without any framework or library. We will start with the native <dialog> element, then build a reusable Modal class that handles focus trapping, keyboard navigation, scroll locking, CSS animations, stacking, and a promise-based API. By the end, you will have a component you can drop into any project.
Prerequisites
- Solid understanding of the DOM (querySelector, addEventListener, createElement)
- Familiarity with CSS positioning and z-index stacking contexts
- Basic knowledge of JavaScript promises
- Understanding of ARIA attributes (helpful but not required — we cover them here)
The HTML Dialog Element
The <dialog> element is a built-in HTML element designed specifically for modal and non-modal dialogs. Browser support is excellent — all modern browsers support it fully.
<dialog id="myDialog">
<h2>Dialog Title</h2>
<p>This is a native dialog.</p>
<button id="closeBtn">Close</button>
</dialog>
<button id="openBtn">Open Dialog</button>
The element comes with three key methods:
var dialog = document.getElementById('myDialog');
var openBtn = document.getElementById('openBtn');
var closeBtn = document.getElementById('closeBtn');
// showModal() opens as a modal with a backdrop — blocks interaction with the page
openBtn.addEventListener('click', function() {
dialog.showModal();
});
// show() opens as a non-modal — user can still interact with the rest of the page
// dialog.show();
// close() closes the dialog and optionally sets a return value
closeBtn.addEventListener('click', function() {
dialog.close('user-dismissed');
});
// The returnValue property holds whatever string you passed to close()
dialog.addEventListener('close', function() {
console.log('Dialog closed with value:', dialog.returnValue);
});
The showModal() method does a lot of heavy lifting. It automatically adds a ::backdrop pseudo-element, traps focus inside the dialog, handles Escape key to close, and places the dialog in the top layer of the document (above all z-index stacking contexts). For many use cases, this is all you need.
Forms Inside Native Dialogs
A <form> with method="dialog" inside a <dialog> will close the dialog when submitted, setting the returnValue to the submitting button's value:
<dialog id="confirmDialog">
<form method="dialog">
<p>Are you sure you want to delete this item?</p>
<button value="cancel">Cancel</button>
<button value="confirm">Delete</button>
</form>
</dialog>
var confirmDialog = document.getElementById('confirmDialog');
confirmDialog.addEventListener('close', function() {
if (confirmDialog.returnValue === 'confirm') {
deleteItem();
}
});
Dialog vs Custom Modal: When to Use Each
Use the native <dialog> element when:
- You need a simple confirmation, alert, or form prompt
- You want built-in accessibility and keyboard handling for free
- You do not need custom animations beyond what CSS can target on
::backdropand the element itself - You are fine with the browser's default stacking behavior
Build a custom modal when:
- You need full control over open/close animations (multi-step transitions, spring physics)
- You need to stack multiple modals with custom z-index management
- You need to prevent closing during async operations (the native dialog always closes on Escape)
- You want a promise-based API that returns structured data
- You need to support older browsers that lack
<dialog>support
In practice, I use native <dialog> for quick confirmations and custom modals for anything more involved.
Building a Custom Modal from Scratch
The Basic Structure
Every custom modal needs three pieces: an overlay (backdrop), a content container, and a close mechanism.
<div class="modal-overlay" id="modal1">
<div class="modal-content" role="dialog" aria-modal="true" aria-labelledby="modal1-title">
<button class="modal-close" aria-label="Close dialog">×</button>
<h2 id="modal1-title">Modal Title</h2>
<p>Modal body content goes here.</p>
</div>
</div>
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-overlay.is-open {
display: flex;
}
.modal-content {
background: #fff;
border-radius: 8px;
padding: 24px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
position: relative;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
}
.modal-close {
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
padding: 4px 8px;
line-height: 1;
}
var overlay = document.getElementById('modal1');
var closeBtn = overlay.querySelector('.modal-close');
function openModal() {
overlay.classList.add('is-open');
}
function closeModal() {
overlay.classList.remove('is-open');
}
closeBtn.addEventListener('click', closeModal);
This works, but it is missing critical pieces: focus management, keyboard handling, scroll locking, and proper accessibility. Let us add those one at a time.
Focus Trapping Inside Modals
When a modal is open, pressing Tab should cycle through only the focusable elements inside the modal. If focus escapes to the page behind, screen reader users and keyboard-only users will get lost.
var FOCUSABLE_SELECTORS = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function trapFocus(modalElement) {
var focusableElements = modalElement.querySelectorAll(FOCUSABLE_SELECTORS);
var firstFocusable = focusableElements[0];
var lastFocusable = focusableElements[focusableElements.length - 1];
function handleTabKey(e) {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// Shift+Tab: if on first element, wrap to last
if (document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
}
} else {
// Tab: if on last element, wrap to first
if (document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
}
}
modalElement.addEventListener('keydown', handleTabKey);
// Focus the first focusable element
if (firstFocusable) {
firstFocusable.focus();
}
// Return cleanup function
return function() {
modalElement.removeEventListener('keydown', handleTabKey);
};
}
The key detail here is returning a cleanup function. Every event listener you attach when opening a modal must be removed when closing it. Skipping this causes memory leaks, especially in single-page applications where modals are created and destroyed repeatedly.
Keyboard Handling
Beyond focus trapping, modals should respond to Escape to close. This is expected behavior — users will press Escape instinctively.
function handleEscape(e) {
if (e.key === 'Escape') {
closeModal();
}
}
function openModal() {
overlay.classList.add('is-open');
document.addEventListener('keydown', handleEscape);
}
function closeModal() {
overlay.classList.remove('is-open');
document.removeEventListener('keydown', handleEscape);
}
Scroll Locking
When a modal is open, the page behind it should not scroll. Without this, users can scroll the background content while the modal is displayed, which is disorienting.
function lockScroll() {
var scrollY = window.scrollY;
document.body.style.position = 'fixed';
document.body.style.top = '-' + scrollY + 'px';
document.body.style.width = '100%';
document.body.dataset.scrollPosition = scrollY;
}
function unlockScroll() {
var scrollY = document.body.dataset.scrollPosition || 0;
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
window.scrollTo(0, parseInt(scrollY));
}
The technique of setting position: fixed on the body and storing the scroll position prevents the page from jumping to the top when the modal opens. When the modal closes, we restore the scroll position. This is more reliable than overflow: hidden alone, which can cause layout shifts on pages with scrollbars.
Accessible Modal Markup
Proper ARIA attributes make modals usable with screen readers. Here is what each attribute does:
role="dialog"— tells assistive technology this is a dialogaria-modal="true"— indicates the dialog is modal (content behind is inert)aria-labelledby— points to the element that labels the dialog (usually the heading)aria-describedby— points to the element that describes the dialog's purpose (optional)
<div class="modal-overlay">
<div class="modal-content"
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc">
<h2 id="dialog-title">Confirm Deletion</h2>
<p id="dialog-desc">This action cannot be undone. The item will be permanently removed.</p>
<button>Cancel</button>
<button>Delete</button>
</div>
</div>
When the modal opens, you should also set aria-hidden="true" on the main page content behind the modal. This tells screen readers to ignore everything outside the dialog:
var mainContent = document.getElementById('main-content');
function openModal() {
mainContent.setAttribute('aria-hidden', 'true');
overlay.classList.add('is-open');
}
function closeModal() {
mainContent.removeAttribute('aria-hidden');
overlay.classList.remove('is-open');
}
Animation for Open and Close
CSS transitions make modals feel polished. The trick is animating both the overlay fade and the content scale/slide separately.
.modal-overlay {
display: flex;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0);
z-index: 1000;
justify-content: center;
align-items: center;
visibility: hidden;
transition: background 0.3s ease, visibility 0s 0.3s;
}
.modal-overlay.is-open {
background: rgba(0, 0, 0, 0.5);
visibility: visible;
transition: background 0.3s ease, visibility 0s 0s;
}
.modal-content {
transform: translateY(-20px) scale(0.95);
opacity: 0;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.modal-overlay.is-open .modal-content {
transform: translateY(0) scale(1);
opacity: 1;
}
The visibility trick is important. We use visibility: hidden instead of display: none because display cannot be transitioned. The delayed visibility transition on close lets the fade-out animation complete before hiding the element.
When closing, you need to wait for the animation to finish before cleaning up:
function closeModal() {
overlay.classList.remove('is-open');
// Wait for transition to finish before cleanup
setTimeout(function() {
unlockScroll();
restoreFocus();
}, 300);
}
Stacking Multiple Modals
Sometimes you need to open a modal on top of another modal — a confirmation dialog inside a settings panel, for example. This requires z-index management.
var modalStack = [];
var BASE_Z_INDEX = 1000;
function getNextZIndex() {
return BASE_Z_INDEX + (modalStack.length * 10);
}
function pushModal(modal) {
var zIndex = getNextZIndex();
modal.overlay.style.zIndex = zIndex;
modalStack.push(modal);
}
function popModal() {
var modal = modalStack.pop();
if (modal) {
modal.close();
}
return modal;
}
Each new modal gets a higher z-index, ensuring it appears above the previous one. When you close the top modal, the one beneath it becomes active again.
Confirmation Dialogs with Promises
A promise-based API makes confirmation dialogs much easier to use in application code. Instead of callbacks, you can await the result:
function confirm(message, options) {
options = options || {};
return new Promise(function(resolve) {
var overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = '<div class="modal-content" role="alertdialog" aria-modal="true">' +
'<p>' + message + '</p>' +
'<div class="modal-actions">' +
'<button class="btn-cancel">' + (options.cancelText || 'Cancel') + '</button>' +
'<button class="btn-confirm">' + (options.confirmText || 'Confirm') + '</button>' +
'</div></div>';
var cancelBtn = overlay.querySelector('.btn-cancel');
var confirmBtn = overlay.querySelector('.btn-confirm');
function cleanup(result) {
overlay.classList.remove('is-open');
setTimeout(function() {
document.body.removeChild(overlay);
resolve(result);
}, 300);
}
cancelBtn.addEventListener('click', function() { cleanup(false); });
confirmBtn.addEventListener('click', function() { cleanup(true); });
document.body.appendChild(overlay);
// Force reflow before adding class for animation
overlay.offsetHeight;
overlay.classList.add('is-open');
confirmBtn.focus();
});
}
// Usage
confirm('Delete this item?', { confirmText: 'Delete' }).then(function(confirmed) {
if (confirmed) {
deleteItem();
}
});
Note the role="alertdialog" instead of role="dialog". This tells screen readers that the dialog requires immediate attention and a response.
Form Modals That Return Data
Form modals extend the promise pattern to return structured data:
function formModal(title, fields) {
return new Promise(function(resolve) {
var formHtml = fields.map(function(field) {
return '<label for="modal-' + field.name + '">' + field.label + '</label>' +
'<input type="' + (field.type || 'text') + '" id="modal-' + field.name + '" name="' + field.name + '"' +
(field.required ? ' required' : '') + '>';
}).join('');
var overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = '<div class="modal-content" role="dialog" aria-modal="true">' +
'<h2>' + title + '</h2>' +
'<form>' + formHtml +
'<div class="modal-actions">' +
'<button type="button" class="btn-cancel">Cancel</button>' +
'<button type="submit" class="btn-submit">Submit</button>' +
'</div></form></div>';
var form = overlay.querySelector('form');
var cancelBtn = overlay.querySelector('.btn-cancel');
function cleanup(data) {
overlay.classList.remove('is-open');
setTimeout(function() {
document.body.removeChild(overlay);
resolve(data);
}, 300);
}
cancelBtn.addEventListener('click', function() { cleanup(null); });
form.addEventListener('submit', function(e) {
e.preventDefault();
var formData = new FormData(form);
var data = {};
formData.forEach(function(value, key) {
data[key] = value;
});
cleanup(data);
});
document.body.appendChild(overlay);
overlay.offsetHeight;
overlay.classList.add('is-open');
var firstInput = overlay.querySelector('input');
if (firstInput) firstInput.focus();
});
}
// Usage
formModal('Add Contact', [
{ name: 'name', label: 'Name', required: true },
{ name: 'email', label: 'Email', type: 'email', required: true }
]).then(function(data) {
if (data) {
saveContact(data);
}
});
Image Lightbox Modal
A lightbox is just a modal with an image as content. The key differences are sizing behavior and optional navigation:
function lightbox(src, alt) {
var overlay = document.createElement('div');
overlay.className = 'modal-overlay lightbox-overlay';
overlay.innerHTML = '<div class="lightbox-content" role="dialog" aria-modal="true" aria-label="' + (alt || 'Image preview') + '">' +
'<button class="modal-close" aria-label="Close">×</button>' +
'<img src="' + src + '" alt="' + (alt || '') + '">' +
'</div>';
var closeBtn = overlay.querySelector('.modal-close');
closeBtn.addEventListener('click', function() {
overlay.classList.remove('is-open');
setTimeout(function() {
document.body.removeChild(overlay);
}, 300);
});
overlay.addEventListener('click', function(e) {
if (e.target === overlay) {
closeBtn.click();
}
});
document.body.appendChild(overlay);
overlay.offsetHeight;
overlay.classList.add('is-open');
closeBtn.focus();
}
.lightbox-content {
max-width: 90vw;
max-height: 90vh;
padding: 0;
background: transparent;
box-shadow: none;
}
.lightbox-content img {
display: block;
max-width: 100%;
max-height: 85vh;
object-fit: contain;
border-radius: 4px;
}
Responsive Modals
On small screens, modals often work better as full-screen panels. A media query handles this cleanly:
@media (max-width: 600px) {
.modal-content {
max-width: 100%;
width: 100%;
height: 100%;
max-height: 100%;
border-radius: 0;
margin: 0;
}
.modal-overlay {
align-items: stretch;
}
}
For modals that should always be full-screen on mobile but centered on desktop, add a modifier class:
.modal-content.modal-responsive {
/* Desktop: constrained */
}
@media (max-width: 600px) {
.modal-content.modal-responsive {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
max-width: none;
max-height: none;
border-radius: 0;
overflow-y: auto;
}
}
Closing on Backdrop Click
Users expect to close a modal by clicking the dark area outside it. Handle this carefully — you need to check that the click target is the overlay itself, not a child element:
overlay.addEventListener('click', function(e) {
if (e.target === overlay) {
closeModal();
}
});
An alternative approach uses mousedown and mouseup to avoid accidental closes when the user starts a click inside the content and drags to the overlay:
var mouseDownTarget = null;
overlay.addEventListener('mousedown', function(e) {
mouseDownTarget = e.target;
});
overlay.addEventListener('mouseup', function(e) {
if (mouseDownTarget === overlay && e.target === overlay) {
closeModal();
}
mouseDownTarget = null;
});
This is a small detail that makes a big difference in user experience, especially with form modals where users might select text near the edges.
Preventing Close During Async Operations
Sometimes you need to prevent the modal from closing while an operation is in progress — submitting a form, uploading a file, running a long task. A loading flag controls this:
var isLoading = false;
function setLoading(loading) {
isLoading = loading;
var submitBtn = overlay.querySelector('.btn-submit');
submitBtn.disabled = loading;
submitBtn.textContent = loading ? 'Saving...' : 'Save';
}
function handleEscape(e) {
if (e.key === 'Escape' && !isLoading) {
closeModal();
}
}
overlay.addEventListener('click', function(e) {
if (e.target === overlay && !isLoading) {
closeModal();
}
});
// During async operation
setLoading(true);
saveData().then(function(result) {
setLoading(false);
closeModal();
}).catch(function(err) {
setLoading(false);
showError(err.message);
});
Modal Manager Pattern
For complex applications with many modal types, a central manager keeps things organized:
var ModalManager = (function() {
var stack = [];
var registry = {};
function register(name, factory) {
registry[name] = factory;
}
function open(name, data) {
var factory = registry[name];
if (!factory) {
throw new Error('Unknown modal: ' + name);
}
var modal = factory(data);
stack.push(modal);
modal.show();
return modal.promise;
}
function closeCurrent() {
var modal = stack[stack.length - 1];
if (modal && !modal.isLoading) {
modal.close();
stack.pop();
}
}
function closeAll() {
while (stack.length > 0) {
var modal = stack.pop();
modal.close();
}
}
return {
register: register,
open: open,
closeCurrent: closeCurrent,
closeAll: closeAll,
getStackSize: function() { return stack.length; }
};
})();
// Register modal types
ModalManager.register('confirm', function(data) {
return createConfirmModal(data.message, data.options);
});
ModalManager.register('editUser', function(data) {
return createUserFormModal(data.user);
});
// Use anywhere in your app
ModalManager.open('confirm', {
message: 'Delete user?',
options: { confirmText: 'Delete' }
}).then(function(confirmed) {
if (confirmed) {
return ModalManager.open('confirm', {
message: 'This is permanent. Are you really sure?'
});
}
return false;
}).then(function(confirmed) {
if (confirmed) {
deleteUser();
}
});
Testing Modals
Modal dialogs are notoriously tricky to test. Focus management, keyboard events, and async behavior all need coverage.
// Test: modal opens and focuses first focusable element
function testModalFocus() {
var modal = new Modal({ content: '<button id="btn1">First</button><button id="btn2">Second</button>' });
modal.open();
var activeElement = document.activeElement;
var firstButton = modal.element.querySelector('#btn1');
console.assert(activeElement === firstButton, 'First focusable element should receive focus');
modal.close();
}
// Test: Escape key closes the modal
function testEscapeClose() {
var modal = new Modal({ content: '<p>Test</p>' });
modal.open();
var event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true });
document.dispatchEvent(event);
console.assert(!modal.isOpen, 'Modal should be closed after Escape');
}
// Test: backdrop click closes the modal
function testBackdropClick() {
var modal = new Modal({ content: '<p>Test</p>' });
modal.open();
var clickEvent = new MouseEvent('click', { bubbles: true });
modal.overlay.dispatchEvent(clickEvent);
console.assert(!modal.isOpen, 'Modal should close on backdrop click');
}
// Test: focus restoration
function testFocusRestore() {
var trigger = document.createElement('button');
document.body.appendChild(trigger);
trigger.focus();
var modal = new Modal({ content: '<p>Test</p>' });
modal.open();
modal.close();
setTimeout(function() {
console.assert(document.activeElement === trigger, 'Focus should return to trigger element');
document.body.removeChild(trigger);
}, 350);
}
For automated testing with frameworks like Playwright or Cypress, remember to account for animation delays and use waitFor assertions on visibility.
Memory Leak Prevention
Every event listener attached when a modal opens must be removed when it closes. This is the most common source of memory leaks with modal implementations.
function Modal(options) {
this.cleanupFunctions = [];
}
Modal.prototype.addListener = function(element, event, handler) {
element.addEventListener(event, handler);
this.cleanupFunctions.push(function() {
element.removeEventListener(event, handler);
});
};
Modal.prototype.destroy = function() {
this.cleanupFunctions.forEach(function(cleanup) {
cleanup();
});
this.cleanupFunctions = [];
if (this.overlay && this.overlay.parentNode) {
this.overlay.parentNode.removeChild(this.overlay);
}
this.overlay = null;
this.element = null;
};
Track every listener you add and remove them all on destroy. If you create modal DOM elements dynamically, remove them from the document as well. Setting references to null helps the garbage collector reclaim memory.
Complete Working Example
Here is a complete, reusable Modal class that brings together everything we have covered. This supports alert, confirm, form, and custom content modals with focus trapping, keyboard navigation, scroll lock, CSS animations, stacking, and a promise-based API.
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Modal Demo</title>
<link rel="stylesheet" href="modal.css">
</head>
<body>
<div id="app">
<h1>Modal Dialog Demo</h1>
<button id="alertBtn">Show Alert</button>
<button id="confirmBtn">Show Confirm</button>
<button id="formBtn">Show Form</button>
<button id="customBtn">Show Custom</button>
<button id="stackBtn">Show Stacked</button>
<div id="output"></div>
</div>
<script src="modal.js"></script>
<script>
var output = document.getElementById('output');
document.getElementById('alertBtn').addEventListener('click', function() {
Modal.alert('Operation completed successfully.');
});
document.getElementById('confirmBtn').addEventListener('click', function() {
Modal.confirm('Are you sure you want to proceed?').then(function(result) {
output.textContent = 'Confirm result: ' + result;
});
});
document.getElementById('formBtn').addEventListener('click', function() {
Modal.form('Create Account', [
{ name: 'username', label: 'Username', required: true },
{ name: 'email', label: 'Email', type: 'email', required: true },
{ name: 'role', label: 'Role', type: 'select', options: ['Admin', 'Editor', 'Viewer'] }
]).then(function(data) {
output.textContent = data ? 'Form data: ' + JSON.stringify(data) : 'Form cancelled';
});
});
document.getElementById('customBtn').addEventListener('click', function() {
var modal = new Modal({
title: 'Custom Content',
content: '<p>This modal has <strong>custom HTML</strong> content.</p>' +
'<p>You can put anything here.</p>',
buttons: [
{ text: 'Got It', value: 'ok', primary: true }
]
});
modal.open().then(function(value) {
output.textContent = 'Custom result: ' + value;
});
});
document.getElementById('stackBtn').addEventListener('click', function() {
var first = new Modal({
title: 'First Modal',
content: '<p>Click the button below to open another modal on top.</p>' +
'<button id="openSecond">Open Second Modal</button>',
buttons: [{ text: 'Close', value: 'close' }],
onReady: function(modalEl) {
modalEl.querySelector('#openSecond').addEventListener('click', function() {
Modal.confirm('This is stacked on top. Close it?').then(function(result) {
output.textContent = 'Stacked confirm: ' + result;
});
});
}
});
first.open();
});
</script>
</body>
</html>
CSS (modal.css)
/* Modal Overlay */
.modal-overlay {
display: flex;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0);
z-index: 1000;
justify-content: center;
align-items: center;
visibility: hidden;
transition: background 0.25s ease, visibility 0s 0.25s;
}
.modal-overlay.is-open {
background: rgba(0, 0, 0, 0.5);
visibility: visible;
transition: background 0.25s ease, visibility 0s 0s;
}
/* Modal Content */
.modal-content {
background: #ffffff;
border-radius: 8px;
padding: 24px;
max-width: 480px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
position: relative;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
transform: translateY(-16px) scale(0.96);
opacity: 0;
transition: transform 0.25s ease, opacity 0.25s ease;
}
.modal-overlay.is-open .modal-content {
transform: translateY(0) scale(1);
opacity: 1;
}
/* Close Button */
.modal-close-btn {
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
font-size: 22px;
cursor: pointer;
padding: 4px 8px;
line-height: 1;
color: #666;
border-radius: 4px;
}
.modal-close-btn:hover {
background: #f0f0f0;
color: #333;
}
/* Title */
.modal-title {
margin: 0 0 16px 0;
font-size: 20px;
font-weight: 600;
padding-right: 32px;
}
/* Body */
.modal-body {
margin-bottom: 20px;
line-height: 1.6;
color: #333;
}
/* Actions */
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.modal-btn {
padding: 8px 20px;
border-radius: 4px;
border: 1px solid #ccc;
background: #fff;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.modal-btn:hover {
background: #f5f5f5;
}
.modal-btn.primary {
background: #2563eb;
color: #fff;
border-color: #2563eb;
}
.modal-btn.primary:hover {
background: #1d4ed8;
}
.modal-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Form Fields */
.modal-field {
margin-bottom: 14px;
}
.modal-field label {
display: block;
margin-bottom: 4px;
font-weight: 500;
font-size: 14px;
color: #444;
}
.modal-field input,
.modal-field select,
.modal-field textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.modal-field input:focus,
.modal-field select:focus,
.modal-field textarea:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
/* Responsive */
@media (max-width: 600px) {
.modal-content {
max-width: 100%;
width: 100%;
height: 100%;
max-height: 100%;
border-radius: 0;
display: flex;
flex-direction: column;
}
.modal-body {
flex: 1;
overflow-y: auto;
}
.modal-overlay {
align-items: stretch;
}
}
JavaScript (modal.js)
(function(global) {
'use strict';
var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), ' +
'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
var BASE_Z = 1000;
var ANIM_DURATION = 250;
var stack = [];
var idCounter = 0;
// ---- Scroll Lock ----
var scrollLockCount = 0;
var savedScrollY = 0;
function lockScroll() {
if (scrollLockCount === 0) {
savedScrollY = window.scrollY;
document.body.style.position = 'fixed';
document.body.style.top = '-' + savedScrollY + 'px';
document.body.style.width = '100%';
}
scrollLockCount++;
}
function unlockScroll() {
scrollLockCount--;
if (scrollLockCount <= 0) {
scrollLockCount = 0;
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
window.scrollTo(0, savedScrollY);
}
}
// ---- Modal Constructor ----
function Modal(options) {
options = options || {};
this.id = 'modal-' + (++idCounter);
this.title = options.title || '';
this.content = options.content || '';
this.buttons = options.buttons || [];
this.closable = options.closable !== false;
this.onReady = options.onReady || null;
this.isOpen = false;
this.isLoading = false;
this.resolve = null;
this.previousFocus = null;
this.cleanups = [];
this.overlay = null;
this.element = null;
}
// ---- Build DOM ----
Modal.prototype.build = function() {
var self = this;
var titleId = this.id + '-title';
var overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.zIndex = BASE_Z + (stack.length * 10);
var content = document.createElement('div');
content.className = 'modal-content';
content.setAttribute('role', 'dialog');
content.setAttribute('aria-modal', 'true');
if (this.title) {
content.setAttribute('aria-labelledby', titleId);
}
var html = '';
if (this.closable) {
html += '<button class="modal-close-btn" aria-label="Close dialog">×</button>';
}
if (this.title) {
html += '<h2 class="modal-title" id="' + titleId + '">' + this.title + '</h2>';
}
html += '<div class="modal-body">' + this.content + '</div>';
if (this.buttons.length > 0) {
html += '<div class="modal-actions">';
this.buttons.forEach(function(btn, i) {
var cls = 'modal-btn' + (btn.primary ? ' primary' : '');
html += '<button class="' + cls + '" data-modal-btn="' + i + '">' + btn.text + '</button>';
});
html += '</div>';
}
content.innerHTML = html;
overlay.appendChild(content);
this.overlay = overlay;
this.element = content;
// Event: close button
if (this.closable) {
var closeBtn = content.querySelector('.modal-close-btn');
this.addListener(closeBtn, 'click', function() {
self.close(null);
});
}
// Event: action buttons
var actionBtns = content.querySelectorAll('[data-modal-btn]');
for (var i = 0; i < actionBtns.length; i++) {
(function(btn) {
var index = parseInt(btn.getAttribute('data-modal-btn'));
var buttonConfig = self.buttons[index];
self.addListener(btn, 'click', function() {
if (!self.isLoading) {
self.close(buttonConfig.value);
}
});
})(actionBtns[i]);
}
// Event: backdrop click
var mouseDownTarget = null;
this.addListener(overlay, 'mousedown', function(e) {
mouseDownTarget = e.target;
});
this.addListener(overlay, 'mouseup', function(e) {
if (self.closable && !self.isLoading && mouseDownTarget === overlay && e.target === overlay) {
self.close(null);
}
mouseDownTarget = null;
});
// Event: keyboard
this.handleKeydown = function(e) {
if (stack[stack.length - 1] !== self) return;
if (e.key === 'Escape' && self.closable && !self.isLoading) {
e.preventDefault();
self.close(null);
return;
}
if (e.key === 'Tab') {
var focusable = content.querySelectorAll(FOCUSABLE);
if (focusable.length === 0) {
e.preventDefault();
return;
}
var first = focusable[0];
var last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
};
this.addListener(document, 'keydown', this.handleKeydown);
};
// ---- Listener Management ----
Modal.prototype.addListener = function(el, event, handler) {
el.addEventListener(event, handler);
this.cleanups.push(function() {
el.removeEventListener(event, handler);
});
};
// ---- Open ----
Modal.prototype.open = function() {
var self = this;
this.previousFocus = document.activeElement;
this.build();
document.body.appendChild(this.overlay);
lockScroll();
// Force reflow then animate
this.overlay.offsetHeight;
this.overlay.classList.add('is-open');
this.isOpen = true;
stack.push(this);
// Focus first focusable element
var focusTarget = this.element.querySelector(FOCUSABLE);
if (focusTarget) {
focusTarget.focus();
}
if (this.onReady) {
this.onReady(this.element);
}
this.promise = new Promise(function(resolve) {
self.resolve = resolve;
});
return this.promise;
};
// ---- Close ----
Modal.prototype.close = function(value) {
if (!this.isOpen) return;
var self = this;
this.isOpen = false;
this.overlay.classList.remove('is-open');
// Remove from stack
var idx = stack.indexOf(this);
if (idx !== -1) {
stack.splice(idx, 1);
}
setTimeout(function() {
self.destroy();
unlockScroll();
if (self.previousFocus && self.previousFocus.focus) {
self.previousFocus.focus();
}
if (self.resolve) {
self.resolve(value);
}
}, ANIM_DURATION);
};
// ---- Set Loading ----
Modal.prototype.setLoading = function(loading) {
this.isLoading = loading;
var btns = this.element.querySelectorAll('.modal-btn');
for (var i = 0; i < btns.length; i++) {
btns[i].disabled = loading;
}
};
// ---- Destroy ----
Modal.prototype.destroy = function() {
this.cleanups.forEach(function(fn) { fn(); });
this.cleanups = [];
if (this.overlay && this.overlay.parentNode) {
this.overlay.parentNode.removeChild(this.overlay);
}
this.overlay = null;
this.element = null;
};
// ---- Static: Alert ----
Modal.alert = function(message, options) {
options = options || {};
var modal = new Modal({
title: options.title || 'Alert',
content: '<p>' + message + '</p>',
buttons: [
{ text: options.buttonText || 'OK', value: 'ok', primary: true }
]
});
return modal.open();
};
// ---- Static: Confirm ----
Modal.confirm = function(message, options) {
options = options || {};
var modal = new Modal({
title: options.title || 'Confirm',
content: '<p>' + message + '</p>',
buttons: [
{ text: options.cancelText || 'Cancel', value: false },
{ text: options.confirmText || 'Confirm', value: true, primary: true }
]
});
return modal.open();
};
// ---- Static: Form ----
Modal.form = function(title, fields, options) {
options = options || {};
var formHtml = '<form class="modal-form">';
fields.forEach(function(field) {
formHtml += '<div class="modal-field">';
formHtml += '<label for="mf-' + field.name + '">' + field.label + '</label>';
if (field.type === 'select') {
formHtml += '<select id="mf-' + field.name + '" name="' + field.name + '">';
(field.options || []).forEach(function(opt) {
formHtml += '<option value="' + opt + '">' + opt + '</option>';
});
formHtml += '</select>';
} else if (field.type === 'textarea') {
formHtml += '<textarea id="mf-' + field.name + '" name="' + field.name + '"' +
(field.required ? ' required' : '') + ' rows="3"></textarea>';
} else {
formHtml += '<input type="' + (field.type || 'text') + '" id="mf-' + field.name +
'" name="' + field.name + '"' + (field.required ? ' required' : '') + '>';
}
formHtml += '</div>';
});
formHtml += '</form>';
var modal = new Modal({
title: title,
content: formHtml,
buttons: [
{ text: options.cancelText || 'Cancel', value: null },
{ text: options.submitText || 'Submit', value: 'submit', primary: true }
],
onReady: function(el) {
var form = el.querySelector('.modal-form');
var submitBtn = el.querySelector('.modal-btn.primary');
submitBtn.addEventListener('click', function(e) {
if (!form.checkValidity()) {
form.reportValidity();
e.stopImmediatePropagation();
return;
}
});
}
});
var originalPromise = modal.open();
return originalPromise.then(function(value) {
if (value === null) return null;
var form = modal.element ? modal.element.querySelector('.modal-form') : null;
if (!form) return null;
var formData = new FormData(form);
var data = {};
formData.forEach(function(val, key) {
data[key] = val;
});
return data;
});
};
// Expose globally
global.Modal = Modal;
})(window);
How It Works
The Modal class manages the full lifecycle of a dialog:
- Construction — stores options; no DOM created yet
open()— builds DOM, appends to body, locks scroll, pushes to stack, starts animation, returns a promise- Focus trap — Tab and Shift+Tab cycle only through elements inside the modal; only the topmost modal in the stack responds to keyboard events
- Stacking — each modal gets a z-index based on its position in the stack;
lockScroll/unlockScrolluse a reference counter so scroll is only restored when all modals close close(value)— triggers closing animation, waits for it to finish, removes DOM, cleans up listeners, restores focus to the element that was active before the modal opened, resolves the promise with the given valuedestroy()— removes all event listeners and DOM nodes, nulls references to prevent memory leaks
Common Issues and Troubleshooting
Focus escapes the modal when using screen readers. Some screen readers allow virtual cursor navigation outside the focus trap. Set aria-hidden="true" on the root application container when the modal is open. The aria-modal="true" attribute on the dialog should handle this in newer screen readers, but aria-hidden on siblings is still the most reliable approach.
Scroll position jumps when the modal opens. This happens if you use overflow: hidden on the body without compensating for the scroll position. The position: fixed technique with stored scroll position (shown in the scroll lock section) prevents this. Make sure you also set width: 100% on the body to prevent layout shifts from the disappearing scrollbar.
Animations do not play on first open. If you toggle the class in the same frame as appending the element to the DOM, the browser may batch the changes and skip the transition. Force a reflow by reading offsetHeight before adding the is-open class. This is a well-known technique — the line this.overlay.offsetHeight exists solely to trigger a synchronous layout.
Modal closes unexpectedly when selecting text. Users clicking inside the modal content and dragging to the overlay trigger a click event on the overlay. The mousedown/mouseup target check pattern solves this by verifying both events originated on the overlay.
Stacked modals all close at once on Escape. Only the topmost modal in the stack should respond to keyboard events. The handleKeydown function checks stack[stack.length - 1] !== self to bail out if this modal is not on top. Call e.preventDefault() to stop the event from propagating to lower modals or the native dialog behavior.
Memory leaks in single-page applications. If you create modals dynamically but forget to call destroy(), the event listeners remain attached to document. The cleanup pattern — storing every listener and removing them all in destroy() — prevents this. Always call destroy() when removing a modal, not just removeChild.
Best Practices
Always restore focus. When a modal closes, return focus to the element that triggered it. This is critical for keyboard users who would otherwise lose their place on the page. Store
document.activeElementbefore opening and call.focus()on it after closing.Keep modal content concise. If your modal needs a scrollbar, you probably need a page instead. Modals work best for focused interactions: confirmations, short forms, quick previews. Long content belongs in a panel, drawer, or separate route.
Use semantic HTML inside modals. Headings, form labels, button elements — all the rules of good markup apply inside modals too. Screen reader users navigate modals the same way they navigate pages.
Test with keyboard only. Put your mouse aside and try to open the modal, interact with everything inside it, and close it using only the keyboard. If any step is awkward or impossible, your implementation has a gap.
Provide multiple close mechanisms. Users should be able to close a modal by clicking the close button, pressing Escape, clicking the backdrop, or pressing a Cancel button. Remove any of these and some users will feel stuck.
Avoid modals for critical information. Do not put error messages, success confirmations, or important instructions inside a modal that auto-closes. Users may miss them. Use inline messages or persistent notifications for critical feedback.
Debounce rapid open/close calls. If a user double-clicks a trigger button, you may end up with two modals. Disable the trigger button or check
isOpenbefore callingopen()again.Design for mobile first. Modals on small screens should go full-screen or nearly full-screen. Touch targets need to be large enough. The close button should be easy to reach with one thumb.
References
- MDN: HTML dialog element — complete reference for the native dialog API
- WAI-ARIA Authoring Practices: Dialog Pattern — the authoritative guide for accessible modal behavior
- MDN: aria-modal — usage and browser support for the aria-modal attribute
- Web.dev: Building a dialog component — Google's guide to modern dialog implementation
- A11y Dialog — a lightweight accessible dialog library, useful as a reference implementation