A centered overlay dialog for confirmations, alerts, forms, and general user interactions.
Uses native <dialog> element with fade animation and focus management.
<!-- Trigger Button -->
<button
data-modal="unique-modal-id"
role="button"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="unique-modal-id">
Open Modal
</button>
<!-- Modal Dialog -->
<dialog
id="unique-modal-id"
class="elv-modal elv-modal--md"
aria-modal="true"
role="dialog">
<div class="elv-modal__inner">
<!-- Header -->
<div class="elv-modal__header">
<div class="elv-modal__header-top">
<div>
<h2 class="elv-modal__title">Modal Title</h2>
<p class="elv-modal__subtitle">Optional subtitle or description</p>
</div>
<button
class="elv-modal__close"
aria-label="Close modal">
<span class="material-symbols-outlined">close</span>
</button>
</div>
</div>
<!-- Body -->
<div class="elv-modal__body">
<p>Modal content goes here. This area is scrollable when content overflows.</p>
</div>
<!-- Footer (optional) -->
<div class="elv-modal__footer">
<button class="btn btn--secondary">Cancel</button>
<button class="btn btn--primary">Confirm</button>
</div>
</div>
</dialog>
<!-- Small: 400px max-width -->
<dialog class="elv-modal elv-modal--sm">...</dialog>
<!-- Medium (default): 560px max-width -->
<dialog class="elv-modal elv-modal--md">...</dialog>
<!-- Large: 800px max-width -->
<dialog class="elv-modal elv-modal--lg">...</dialog>
<!-- Extra Large: 1000px max-width -->
<dialog class="elv-modal elv-modal--xl">...</dialog>
<!-- Add data-dismiss-on-backdrop-click="false" to prevent closing when backdrop is clicked -->
<dialog
id="modal-id"
class="elv-modal elv-modal--md"
data-dismiss-on-backdrop-click="false"
aria-modal="true"
role="dialog">
...
</dialog>
Vanilla JavaScript implementation with focus trap, focus restoration, and accessibility features.
/**
* Modal Controller
*
* Features:
* - Native <dialog> element with showModal() API
* - Fade-in animation (200ms ease-in-out)
* - Centered positioning (default dialog behavior)
* - Focus trap (tab cycles within modal)
* - Focus restoration to trigger element
* - Click outside to close (configurable)
* - ESC key to close (native)
* - Body scroll lock when open
* - ARIA state management
*/
class Modal {
constructor(dialogElement) {
this.dialog = dialogElement;
this.triggerElement = null;
this.focusableElements = [];
// Check if backdrop click should close modal (default: true)
this.dismissOnBackdropClick =
this.dialog.getAttribute('data-dismiss-on-backdrop-click') !== 'false';
this.init();
}
init() {
// Add body scroll lock class (activates when any dialog[open] exists)
if (!document.body.classList.contains('elv-has-dialog-scroll-lock')) {
document.body.classList.add('elv-has-dialog-scroll-lock');
}
// Bind event listeners
this.dialog.addEventListener('click', this.handleBackdropClick.bind(this));
this.dialog.addEventListener('keydown', this.handleKeyDown.bind(this));
// Find and setup close buttons
const closeButtons = this.dialog.querySelectorAll('.elv-modal__close');
closeButtons.forEach(btn => {
btn.addEventListener('click', () => this.close());
});
}
open(triggerElement) {
// Store trigger for focus restoration
this.triggerElement = triggerElement;
// Update trigger ARIA state
if (this.triggerElement) {
this.triggerElement.setAttribute('aria-expanded', 'true');
}
// Close any other open dialogs
document.querySelectorAll('dialog[open]').forEach(d => {
if (d !== this.dialog) {
d.close();
}
});
// Open the dialog
this.dialog.showModal();
// Setup focus trap
this.setupFocusTrap();
// Focus first element
requestAnimationFrame(() => {
this.focusFirstElement();
});
}
close() {
// Close the dialog
this.dialog.close();
// Update trigger ARIA state
if (this.triggerElement) {
this.triggerElement.setAttribute('aria-expanded', 'false');
this.triggerElement.focus();
}
}
setupFocusTrap() {
// Get all focusable elements within dialog
const focusableSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
this.focusableElements = Array.from(
this.dialog.querySelectorAll(focusableSelector)
);
}
focusFirstElement() {
if (this.focusableElements.length > 0) {
this.focusableElements[0].focus();
}
}
handleBackdropClick(event) {
// Close if click is directly on dialog element (backdrop), not on content
// Only if dismissOnBackdropClick is enabled
if (event.target === this.dialog && this.dismissOnBackdropClick) {
this.close();
}
}
handleKeyDown(event) {
// Focus trap: cycle focus within modal
if (event.key === 'Tab' && this.focusableElements.length > 0) {
const firstElement = this.focusableElements[0];
const lastElement = this.focusableElements[this.focusableElements.length - 1];
if (event.shiftKey) {
// Shift+Tab: if on first element, jump to last
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab: if on last element, jump to first
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
}
}
}
// Initialize all modals on page load
document.addEventListener('DOMContentLoaded', () => {
// Create modal instances
const modals = new Map();
document.querySelectorAll('.elv-modal').forEach(dialog => {
modals.set(dialog.id, new Modal(dialog));
});
// Setup trigger buttons
document.querySelectorAll('[data-modal]').forEach(trigger => {
trigger.addEventListener('click', (e) => {
const modalId = trigger.getAttribute('data-modal');
const modal = modals.get(modalId);
if (modal) {
modal.open(trigger);
}
});
// Keyboard support for triggers
trigger.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
trigger.click();
}
});
});
});
aria-controls, aria-haspopup="dialog", aria-expanded) and dialog (aria-modal="true", role="dialog")aria-label="Close modal"ease-in-out easingrgba(32, 31, 35, 0.4))data-dismiss-on-backdrop-click="false":has() selector"false" on the <dialog> element to prevent closing when clicking the backdrop. Useful for required forms or critical confirmations. Default is "true" (clicking backdrop closes modal).#ffffff (neutral-0)0 8px 32px 0 rgba(32, 31, 35, 0.12)rgba(32, 31, 35, 0.40)#f7f6f7 (neutral-5)#e7e5e8 (medium)
Uses native <dialog> element (Chrome 37+, Firefox 98+, Safari 15.4+) and CSS :has()
selector (Chrome 105+, Firefox 121+, Safari 15.4+). For older browsers, consider a polyfill or fallback implementation.