Created
February 8, 2026 22:59
-
-
Save brandonhimpfen/5a78c5c07f41d3a368d794448c5861b5 to your computer and use it in GitHub Desktop.
Minimal accessible HTML modal markup (ARIA + focus target) with a tiny JS controller (open/close + Escape + backdrop click).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Minimal Accessible Modal</title> | |
| <style> | |
| /* Minimal styling just to make the demo usable */ | |
| .modal-backdrop[hidden] { display: none; } | |
| .modal-backdrop { | |
| position: fixed; inset: 0; | |
| background: rgba(0,0,0,0.5); | |
| display: grid; place-items: center; | |
| padding: 1rem; | |
| } | |
| .modal { | |
| background: #fff; | |
| width: min(520px, 100%); | |
| border-radius: 12px; | |
| padding: 1rem; | |
| box-shadow: 0 10px 30px rgba(0,0,0,0.25); | |
| } | |
| .modal-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } | |
| .modal-title { margin: 0; font-size: 1.1rem; } | |
| .modal-close { font: inherit; } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Trigger --> | |
| <button type="button" data-modal-open="demo-modal"> | |
| Open modal | |
| </button> | |
| <!-- Backdrop --> | |
| <div | |
| class="modal-backdrop" | |
| id="demo-modal-backdrop" | |
| hidden | |
| > | |
| <!-- | |
| role="dialog" + aria-modal="true" indicates a modal dialog. | |
| aria-labelledby points to the title element inside. | |
| aria-describedby points to the main description text. | |
| --> | |
| <div | |
| class="modal" | |
| role="dialog" | |
| aria-modal="true" | |
| aria-labelledby="demo-modal-title" | |
| aria-describedby="demo-modal-desc" | |
| data-modal="demo-modal" | |
| tabindex="-1" | |
| > | |
| <div class="modal-header"> | |
| <h2 class="modal-title" id="demo-modal-title">Modal title</h2> | |
| <button type="button" class="modal-close" data-modal-close aria-label="Close dialog"> | |
| ✕ | |
| </button> | |
| </div> | |
| <div id="demo-modal-desc"> | |
| <p>This is minimal accessible modal markup with basic open/close logic.</p> | |
| <p>Press Escape to close, or click the backdrop.</p> | |
| </div> | |
| <div style="margin-top: 1rem; display: flex; gap: .5rem; justify-content: flex-end;"> | |
| <button type="button" data-modal-close>Cancel</button> | |
| <button type="button">Confirm</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // Minimal accessible modal controller: | |
| // - Opens/closes modal | |
| // - Restores focus to the trigger | |
| // - Closes on Escape | |
| // - Closes on backdrop click | |
| // Note: For a fully robust solution, add focus trapping. This is kept minimal on purpose. | |
| const openButtons = document.querySelectorAll("[data-modal-open]"); | |
| const closeSelectors = "[data-modal-close]"; | |
| let lastActiveElement = null; | |
| function openModal(modalId) { | |
| const modal = document.querySelector(`[data-modal="${modalId}"]`); | |
| const backdrop = document.getElementById(`${modalId}-backdrop`); | |
| if (!modal || !backdrop) return; | |
| lastActiveElement = document.activeElement; | |
| backdrop.hidden = false; | |
| // Prevent background scroll (optional but common) | |
| document.body.style.overflow = "hidden"; | |
| // Focus the dialog container (or choose the first focusable control) | |
| modal.focus(); | |
| } | |
| function closeModal(modalId) { | |
| const backdrop = document.getElementById(`${modalId}-backdrop`); | |
| if (!backdrop) return; | |
| backdrop.hidden = true; | |
| document.body.style.overflow = ""; | |
| // Restore focus to the element that opened the modal | |
| if (lastActiveElement && typeof lastActiveElement.focus === "function") { | |
| lastActiveElement.focus(); | |
| } | |
| lastActiveElement = null; | |
| } | |
| // Open handlers | |
| openButtons.forEach(btn => { | |
| btn.addEventListener("click", () => openModal(btn.dataset.modalOpen)); | |
| }); | |
| // Close handlers (buttons inside) | |
| document.addEventListener("click", (e) => { | |
| const closeBtn = e.target.closest(closeSelectors); | |
| if (!closeBtn) return; | |
| const modal = closeBtn.closest("[data-modal]"); | |
| if (!modal) return; | |
| closeModal(modal.dataset.modal); | |
| }); | |
| // Close on Escape | |
| document.addEventListener("keydown", (e) => { | |
| if (e.key !== "Escape") return; | |
| const openBackdrop = document.querySelector(".modal-backdrop:not([hidden])"); | |
| if (!openBackdrop) return; | |
| const openModalEl = openBackdrop.querySelector("[data-modal]"); | |
| if (!openModalEl) return; | |
| closeModal(openModalEl.dataset.modal); | |
| }); | |
| // Close on backdrop click (but not when clicking inside the dialog) | |
| document.addEventListener("click", (e) => { | |
| const backdrop = e.target.classList && e.target.classList.contains("modal-backdrop") ? e.target : null; | |
| if (!backdrop || backdrop.hidden) return; | |
| const modal = backdrop.querySelector("[data-modal]"); | |
| if (!modal) return; | |
| closeModal(modal.dataset.modal); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment