UI Components Medium
ARIA Modal Pattern
Accessible modal dialog with focus trap, escape-to-close and screen reader announcements following WAI-ARIA dialog pattern.
Open in Lab
MCP
vanilla-js css
Targets: JS HTML
Code
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: #0a0a0a;
color: #e4e4e7;
line-height: 1.6;
min-height: 100vh;
}
body.modal-open {
overflow: hidden;
}
.page {
transition: filter 0.2s;
}
body.modal-open .page {
filter: blur(2px);
}
.demo {
max-width: 820px;
margin: 0 auto;
padding: 3rem 1.5rem;
}
.demo-title {
font-size: 1.75rem;
font-weight: 700;
color: #fafafa;
letter-spacing: -0.02em;
}
.demo-sub {
color: #a1a1aa;
margin-top: 0.25rem;
font-size: 0.95rem;
}
/* Trigger Cards */
.trigger-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
margin-top: 2rem;
}
.trigger-card {
background: #111113;
border: 1px solid #27272a;
border-radius: 12px;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.trigger-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.25rem;
}
.trigger-icon--danger {
background: rgba(239, 68, 68, 0.12);
color: #f87171;
}
.trigger-icon--info {
background: rgba(59, 130, 246, 0.12);
color: #60a5fa;
}
.trigger-icon--neutral {
background: rgba(161, 161, 170, 0.12);
color: #a1a1aa;
}
.trigger-title {
font-size: 1rem;
font-weight: 600;
color: #fafafa;
}
.trigger-desc {
font-size: 0.8rem;
color: #71717a;
flex: 1;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.55rem 1rem;
font-size: 0.825rem;
font-weight: 500;
font-family: inherit;
border: 1px solid #27272a;
border-radius: 8px;
background: #18181b;
color: #e4e4e7;
cursor: pointer;
transition: all 0.15s;
}
.btn:hover {
background: #27272a;
border-color: #3f3f46;
}
.btn:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.btn--primary {
background: #3b82f6;
border-color: #3b82f6;
color: #fff;
}
.btn--primary:hover {
background: #2563eb;
border-color: #2563eb;
}
.btn--danger {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
color: #f87171;
}
.btn--danger:hover {
background: rgba(239, 68, 68, 0.25);
}
.btn--ghost {
background: transparent;
border-color: transparent;
color: #a1a1aa;
}
.btn--ghost:hover {
background: #1e1e22;
color: #e4e4e7;
}
/* Features Panel */
.features-panel {
margin-top: 2rem;
background: #111113;
border: 1px solid #27272a;
border-radius: 12px;
padding: 1.5rem;
}
.features-title {
font-size: 1rem;
font-weight: 600;
color: #fafafa;
margin-bottom: 0.75rem;
}
.features-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.features-list li {
font-size: 0.825rem;
color: #a1a1aa;
padding-left: 1.25rem;
position: relative;
}
.features-list li::before {
content: "";
position: absolute;
left: 0;
top: 0.55em;
width: 6px;
height: 6px;
border-radius: 50%;
background: #3b82f6;
}
.features-list code {
background: #1e1e22;
padding: 0.1rem 0.35rem;
border-radius: 4px;
font-size: 0.78rem;
color: #60a5fa;
font-family: "SF Mono", "Fira Code", monospace;
}
kbd {
display: inline-block;
padding: 0.08rem 0.4rem;
background: #1e1e22;
border: 1px solid #3f3f46;
border-radius: 4px;
font-size: 0.73rem;
font-family: "SF Mono", "Fira Code", monospace;
color: #d4d4d8;
}
/* Modal Backdrop */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
animation: backdropIn 0.2s ease-out;
}
.modal-backdrop[hidden] {
display: none;
}
/* Modal */
.modal {
background: #111113;
border: 1px solid #27272a;
border-radius: 14px;
width: 100%;
max-width: 440px;
max-height: 90vh;
overflow-y: auto;
animation: modalIn 0.25s ease-out;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.5);
}
.modal--wide {
max-width: 520px;
}
.modal:focus {
outline: none;
}
.modal-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.25rem 1.25rem 0;
}
.modal-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.modal-icon--danger {
background: rgba(239, 68, 68, 0.12);
color: #f87171;
}
.modal-icon--info {
background: rgba(59, 130, 246, 0.12);
color: #60a5fa;
}
.modal-title {
font-size: 1.05rem;
font-weight: 600;
color: #fafafa;
flex: 1;
}
.modal-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: #71717a;
font-size: 1.4rem;
cursor: pointer;
border-radius: 8px;
transition: all 0.15s;
}
.modal-close:hover {
background: #1e1e22;
color: #e4e4e7;
}
.modal-close:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.modal-body {
padding: 1rem 1.25rem;
}
.modal-desc {
font-size: 0.875rem;
color: #a1a1aa;
line-height: 1.6;
}
.modal-desc strong {
color: #e4e4e7;
}
.changes-list {
margin-top: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.change-item {
font-size: 0.8rem;
color: #d4d4d8;
padding: 0.4rem 0.65rem;
background: #09090b;
border: 1px solid #1e1e22;
border-radius: 6px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0 1.25rem 1.25rem;
}
/* Form inside modal */
.modal-form {
margin-top: 0.75rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: #a1a1aa;
margin-bottom: 0.35rem;
}
.required {
color: #f87171;
}
.form-input {
width: 100%;
padding: 0.6rem 0.85rem;
background: #09090b;
border: 1px solid #27272a;
border-radius: 8px;
color: #e4e4e7;
font-size: 0.875rem;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
.form-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
.form-textarea {
resize: vertical;
min-height: 70px;
}
select.form-input {
cursor: pointer;
}
/* Animations */
@keyframes backdropIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modalIn {
from {
opacity: 0;
transform: scale(0.95) translateY(8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}(() => {
let activeModal = null;
let triggerElement = null;
const FOCUSABLE_SELECTORS =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
function openModal(modalId, trigger) {
const backdrop = document.getElementById(modalId);
if (!backdrop) return;
triggerElement = trigger;
activeModal = backdrop;
backdrop.hidden = false;
document.body.classList.add("modal-open");
// Focus the modal container
const modal = backdrop.querySelector(".modal");
modal.focus();
// Set up focus trap
requestAnimationFrame(() => {
const focusableEls = getFocusableElements(backdrop);
if (focusableEls.length > 0) {
focusableEls[0].focus();
}
});
}
function closeModal() {
if (!activeModal) return;
activeModal.hidden = true;
document.body.classList.remove("modal-open");
// Return focus to trigger
if (triggerElement) {
triggerElement.focus();
}
activeModal = null;
triggerElement = null;
}
function getFocusableElements(container) {
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTORS)).filter(
(el) => !el.closest("[hidden]") && el.offsetParent !== null
);
}
function trapFocus(e) {
if (!activeModal) return;
const focusableEls = getFocusableElements(activeModal);
if (focusableEls.length === 0) return;
const firstEl = focusableEls[0];
const lastEl = focusableEls[focusableEls.length - 1];
if (e.key === "Tab") {
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
}
} else {
// Tab
if (document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
}
}
// Trigger buttons
document.querySelectorAll("[data-modal]").forEach((btn) => {
btn.addEventListener("click", () => {
openModal(btn.dataset.modal, btn);
});
});
// Close buttons
document.querySelectorAll(".modal-close, .modal-cancel").forEach((btn) => {
btn.addEventListener("click", closeModal);
});
// Backdrop click to close
document.querySelectorAll(".modal-backdrop").forEach((backdrop) => {
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) {
closeModal();
}
});
});
// Keyboard: Escape to close, Tab trapping
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && activeModal) {
e.preventDefault();
closeModal();
}
if (e.key === "Tab" && activeModal) {
trapFocus(e);
}
});
// Form submission in modal
const submitBtn = document.getElementById("submit-form");
if (submitBtn) {
submitBtn.addEventListener("click", () => {
const form = document.getElementById("team-form");
const nameInput = document.getElementById("team-name");
const leadSelect = document.getElementById("team-lead");
// Simple validation
let valid = true;
if (!nameInput.value.trim()) {
nameInput.style.borderColor = "#f87171";
valid = false;
} else {
nameInput.style.borderColor = "";
}
if (!leadSelect.value) {
leadSelect.style.borderColor = "#f87171";
valid = false;
} else {
leadSelect.style.borderColor = "";
}
if (valid) {
closeModal();
}
});
}
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ARIA Modal Pattern</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page" id="page-content">
<div class="demo">
<h1 class="demo-title">ARIA Modal Pattern</h1>
<p class="demo-sub">Accessible modal dialogs with focus trap, Escape to close, and screen reader announcements.</p>
<div class="trigger-grid">
<!-- Alert Modal -->
<div class="trigger-card">
<div class="trigger-icon trigger-icon--danger" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</div>
<h3 class="trigger-title">Alert Dialog</h3>
<p class="trigger-desc">Destructive action confirmation with an urgent warning message.</p>
<button class="btn btn--danger" data-modal="modal-alert">Delete Project</button>
</div>
<!-- Confirmation Modal -->
<div class="trigger-card">
<div class="trigger-icon trigger-icon--info" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9 12l2 2 4-4"/></svg>
</div>
<h3 class="trigger-title">Confirmation Dialog</h3>
<p class="trigger-desc">Standard confirmation with approve and cancel actions.</p>
<button class="btn btn--primary" data-modal="modal-confirm">Publish Changes</button>
</div>
<!-- Form Modal -->
<div class="trigger-card">
<div class="trigger-icon trigger-icon--neutral" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</div>
<h3 class="trigger-title">Form Dialog</h3>
<p class="trigger-desc">Modal with form fields, validation, and submit handling.</p>
<button class="btn" data-modal="modal-form">Create Team</button>
</div>
</div>
<div class="features-panel">
<h3 class="features-title">Accessibility Features</h3>
<ul class="features-list">
<li>Focus trap: <kbd>Tab</kbd> and <kbd>Shift+Tab</kbd> cycle within the modal</li>
<li><kbd>Escape</kbd> closes the modal and returns focus to trigger</li>
<li>Background scroll is locked while modal is open</li>
<li><code>role="dialog"</code> + <code>aria-modal="true"</code> for screen readers</li>
<li><code>aria-labelledby</code> and <code>aria-describedby</code> provide context</li>
</ul>
</div>
</div>
</div>
<!-- Alert Modal -->
<div class="modal-backdrop" id="modal-alert" role="dialog" aria-modal="true"
aria-labelledby="alert-title" aria-describedby="alert-desc" hidden>
<div class="modal" tabindex="-1">
<div class="modal-header">
<div class="modal-icon modal-icon--danger" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
</div>
<h2 class="modal-title" id="alert-title">Delete Project?</h2>
<button class="modal-close" aria-label="Close dialog">×</button>
</div>
<div class="modal-body">
<p class="modal-desc" id="alert-desc">
This will permanently delete <strong>"ARIA Components"</strong> and all associated data including files, issues, and team assignments. This action cannot be undone.
</p>
</div>
<div class="modal-footer">
<button class="btn btn--ghost modal-cancel">Cancel</button>
<button class="btn btn--danger">Delete Project</button>
</div>
</div>
</div>
<!-- Confirmation Modal -->
<div class="modal-backdrop" id="modal-confirm" role="dialog" aria-modal="true"
aria-labelledby="confirm-title" aria-describedby="confirm-desc" hidden>
<div class="modal" tabindex="-1">
<div class="modal-header">
<div class="modal-icon modal-icon--info" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9 12l2 2 4-4"/></svg>
</div>
<h2 class="modal-title" id="confirm-title">Publish Changes?</h2>
<button class="modal-close" aria-label="Close dialog">×</button>
</div>
<div class="modal-body">
<p class="modal-desc" id="confirm-desc">
You are about to publish 3 pending changes to production. These changes will be visible to all users immediately.
</p>
<div class="changes-list">
<div class="change-item">Updated landing page hero section</div>
<div class="change-item">Fixed navigation dropdown alignment</div>
<div class="change-item">Added new footer links</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn--ghost modal-cancel">Cancel</button>
<button class="btn btn--primary">Publish Now</button>
</div>
</div>
</div>
<!-- Form Modal -->
<div class="modal-backdrop" id="modal-form" role="dialog" aria-modal="true"
aria-labelledby="form-title" aria-describedby="form-desc" hidden>
<div class="modal modal--wide" tabindex="-1">
<div class="modal-header">
<h2 class="modal-title" id="form-title">Create New Team</h2>
<button class="modal-close" aria-label="Close dialog">×</button>
</div>
<div class="modal-body">
<p class="modal-desc" id="form-desc">Set up a new team and invite members to collaborate.</p>
<form class="modal-form" id="team-form" novalidate>
<div class="form-group">
<label class="form-label" for="team-name">Team Name <span class="required">*</span></label>
<input type="text" id="team-name" class="form-input" placeholder="e.g., Frontend Squad" required />
</div>
<div class="form-group">
<label class="form-label" for="team-desc">Description</label>
<textarea id="team-desc" class="form-input form-textarea" rows="3" placeholder="What does this team work on?"></textarea>
</div>
<div class="form-group">
<label class="form-label" for="team-lead">Team Lead <span class="required">*</span></label>
<select id="team-lead" class="form-input" required>
<option value="">Select a member...</option>
<option value="alice">Alice Lin</option>
<option value="bob">Bob Kim</option>
<option value="carol">Carol Rivera</option>
<option value="dan">Dan Torres</option>
</select>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn--ghost modal-cancel">Cancel</button>
<button class="btn btn--primary" id="submit-form">Create Team</button>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>An accessible modal dialog following the WAI-ARIA dialog pattern with focus trapping, Escape-to-close, and proper screen reader announcements. Focus returns to the trigger element on close, and background scroll is prevented while the modal is open.