Accessible Modal Dialog with HTML, CSS, and Vanilla JavaScript
• Updated 6/6/2026 • modalaccessibilityARIAHTMLCSSJavaScriptvanilla-jsfocus-trapkeyboard-navigationfrontend
One-file front-end editor
Write plain HTML, CSS, JavaScript, or CDN-powered Vue and React demos.
What This Snippet Does
This snippet shows how to build a reusable accessible modal dialog using HTML, CSS, and vanilla JavaScript. It includes keyboard support, focus trapping, ARIA attributes, overlay click handling, and focus restoration when the modal closes.
Real-World Use Case
Use this modal when you need to show important content without sending users to a new page. Common examples include newsletter signup forms, delete confirmations, login prompts, onboarding tips, terms and conditions notices, and checkout warnings.
Why Accessibility Matters
A modal should work for mouse users, keyboard users, and screen reader users. A visually working popup is not enough. Users must be able to open it, understand it, navigate inside it, close it, and return to the correct place afterward.
Key Concepts
- role="dialog" identifies the modal as a dialog for assistive technologies.
- aria-modal="true" tells screen readers that the rest of the page is inactive while the modal is open.
- aria-labelledby connects the dialog to its visible title.
- aria-describedby connects the dialog to supporting description text.
- Focus trapping keeps keyboard focus inside the modal while it is open.
- Escape key support gives users a familiar way to close the dialog.
- Focus restoration sends users back to the button or element that opened the modal.
Complete Snippet Code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Accessible Modal Dialog</title>
<style>
:root {
--modal-radius: 12px;
--modal-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
--modal-overlay: rgba(15, 23, 42, 0.65);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: Arial, sans-serif;
line-height: 1.5;
color: #1f2937;
background: #f8fafc;
padding: 2rem;
}
button,
input {
font: inherit;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 8px;
padding: 0.75rem 1rem;
cursor: pointer;
background: #2563eb;
color: #ffffff;
font-weight: 700;
}
.button:hover {
background: #1d4ed8;
}
.button:focus-visible,
.modal-close:focus-visible,
input:focus-visible {
outline: 3px solid #93c5fd;
outline-offset: 3px;
}
.modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: none;
align-items: center;
justify-content: center;
padding: 1rem;
background: var(--modal-overlay);
}
.modal-overlay.is-open {
display: flex;
}
.modal {
width: min(100%, 520px);
max-height: min(90vh, 700px);
overflow-y: auto;
border-radius: var(--modal-radius);
background: #ffffff;
box-shadow: var(--modal-shadow);
padding: 1.5rem;
}
.modal-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.modal-title {
margin: 0;
font-size: 1.35rem;
line-height: 1.2;
}
.modal-close {
border: 0;
background: transparent;
color: #374151;
cursor: pointer;
font-size: 1.75rem;
line-height: 1;
padding: 0.25rem;
}
.modal-description {
margin-top: 0;
color: #4b5563;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.4rem;
font-weight: 700;
}
input[type="email"] {
width: 100%;
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 0.75rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.25rem;
}
.button-secondary {
background: #e5e7eb;
color: #111827;
}
.button-secondary:hover {
background: #d1d5db;
}
</style>
</head>
<body>
<main>
<h1>Accessible Modal Dialog Demo</h1>
<p>Click the button below to open a keyboard-friendly modal dialog.</p>
<button class="button" type="button" data-open-modal>
Open Newsletter Modal
</button>
</main>
<div
class="modal-overlay"
id="newsletterModal"
role="dialog"
aria-modal="true"
aria-labelledby="modalTitle"
aria-describedby="modalDescription"
aria-hidden="true"
>
<div class="modal" role="document">
<div class="modal-header">
<h2 class="modal-title" id="modalTitle">Subscribe to our newsletter</h2>
<button class="modal-close" type="button" data-close-modal aria-label="Close modal">
×
</button>
</div>
<p class="modal-description" id="modalDescription">
Get practical coding tips, accessibility notes, and frontend snippets delivered to your inbox.
</p>
<form id="newsletterForm">
<div class="form-group">
<label for="email">Email address</label>
<input id="email" name="email" type="email" autocomplete="email" placeholder="you@example.com" required>
</div>
<div class="modal-actions">
<button class="button button-secondary" type="button" data-close-modal>
Cancel
</button>
<button class="button" type="submit">
Subscribe
</button>
</div>
</form>
</div>
</div>
<script>
const openModalButton = document.querySelector('[data-open-modal]');
const modalOverlay = document.getElementById('newsletterModal');
const closeModalButtons = modalOverlay.querySelectorAll('[data-close-modal]');
const form = document.getElementById('newsletterForm');
const focusableSelectors = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(',');
let previouslyFocusedElement = null;
function getFocusableElements() {
return Array.from(modalOverlay.querySelectorAll(focusableSelectors));
}
function openModal() {
previouslyFocusedElement = document.activeElement;
modalOverlay.classList.add('is-open');
modalOverlay.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
const focusableElements = getFocusableElements();
if (focusableElements.length) {
focusableElements[0].focus();
}
document.addEventListener('keydown', handleModalKeydown);
}
function closeModal() {
modalOverlay.classList.remove('is-open');
modalOverlay.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
document.removeEventListener('keydown', handleModalKeydown);
if (previouslyFocusedElement) {
previouslyFocusedElement.focus();
}
}
function handleModalKeydown(event) {
if (event.key === 'Escape') {
event.preventDefault();
closeModal();
return;
}
if (event.key !== 'Tab') return;
const focusableElements = getFocusableElements();
if (!focusableElements.length) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
openModalButton.addEventListener('click', openModal);
closeModalButtons.forEach((button) => {
button.addEventListener('click', closeModal);
});
modalOverlay.addEventListener('click', (event) => {
if (event.target === modalOverlay) {
closeModal();
}
});
form.addEventListener('submit', (event) => {
event.preventDefault();
alert('Thanks for subscribing!');
closeModal();
});
</script>
</body>
</html>
How It Works
- The open button uses
data-open-modalso JavaScript can find it without relying on styling classes. - The modal wrapper uses
role="dialog",aria-modal="true",aria-labelledby, andaria-describedbyto describe the dialog to assistive technologies. - The modal starts hidden with
aria-hidden="true"and without theis-openclass. - When the modal opens, JavaScript stores the previously focused element, shows the modal, disables page scrolling, and moves focus into the dialog.
- The
keydownlistener handlesEscape,Tab, andShift + Tabwhile the modal is open. - When the modal closes, the listener is removed, page scrolling is restored, and focus returns to the original trigger button.
Customization Notes
- Change the modal width by editing
width: min(100%, 520px);inside the.modalrule. - Change the overlay darkness by editing the
--modal-overlayCSS variable. - Add entrance animations with
opacityandtransform, but avoid animations that make the modal hard to use. - Replace the newsletter form with a login form, confirmation message, settings panel, or alert content.
- For destructive actions, make the safest action the easiest to reach and use clear button labels such as
CancelandDelete project.
Common Mistakes
- Using a modal without moving focus into it when it opens.
- Forgetting to return focus to the trigger element when the modal closes.
- Using only mouse click handlers and ignoring keyboard users.
- Leaving the background page scrollable while the modal is open.
- Using vague button labels like
OKwhen a clearer action label would help users. - Putting the modal title in the design but not connecting it with
aria-labelledby. - Removing the visible focus outline, which makes keyboard navigation difficult.
Beginner-Friendly Tips
- Keep the modal HTML near the end of the page so it is easy to find and maintain.
- Use data attributes such as
data-open-modalanddata-close-modalfor JavaScript hooks. - Test the modal without touching your mouse. Open it, tab through it, submit or cancel it, and close it with Escape.
- Use real labels for form fields instead of relying only on placeholders.
Related Practice Ideas
- Convert this modal into a reusable JavaScript class.
- Create multiple modals on the same page using different data attributes.
- Add a confirmation modal before deleting an item from a list.
- Add smooth open and close animations while respecting
prefers-reduced-motion. - Build the same accessible modal pattern in React, Vue, or Laravel Blade.

