Modal
Fixed dialog with backdrop overlay. Focus should be trapped inside while open.
Structure
MODAL PANEL
CONFIRM_ACTION
This action will purge all cached node data. Proceed?
<!-- Overlay -->
<div class="nf-modal-overlay" id="modal-overlay" role="presentation">
<!-- Dialog -->
<div class="nf-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title" tabindex="-1">
<div class="nf-modal-header">
<h2 class="nf-modal-title" id="modal-title">CONFIRM_ACTION</h2>
<button class="nf-modal-close" aria-label="Close">✕</button>
</div>
<div class="nf-modal-body">
<p>This action will purge all cached node data. Proceed?</p>
</div>
<div class="nf-modal-footer">
<button class="nf-button nf-button-ghost">CANCEL</button>
<button class="nf-button nf-button-danger">PURGE</button>
</div>
</div>
</div>Sizes
SIZE VARIANTS
nf-modal-sm 20rem
default 32rem
nf-modal-lg 48rem
nf-modal-xl 64rem
nf-modal-full 100vw × 100svh
<div class="nf-modal nf-modal-sm">...</div> <!-- 20rem -->
<div class="nf-modal">...</div> <!-- default 32rem -->
<div class="nf-modal nf-modal-lg">...</div> <!-- 48rem -->
<div class="nf-modal nf-modal-xl">...</div> <!-- 64rem -->
<div class="nf-modal nf-modal-full">...</div> <!-- 100vw × 100svh -->JavaScript
const overlay = document.getElementById('modal-overlay');
const modal = overlay.querySelector('.nf-modal');
const trigger = document.getElementById('open-modal');
const close = modal.querySelector('.nf-modal-close');
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
let isOpen = false;
function openModal() {
isOpen = true;
overlay.classList.remove('nf-hidden');
overlay.removeAttribute('aria-hidden');
// Keep keyboard and screen-reader focus out of the page behind the dialog.
overlay.parentElement.querySelectorAll(':scope > :not(#modal-overlay)').forEach((el) => (el.inert = true));
modal.focus();
}
function closeModal() {
if (!isOpen) return; // Escape on a closed modal must not steal focus.
isOpen = false;
overlay.classList.add('nf-hidden');
overlay.setAttribute('aria-hidden', 'true');
overlay.parentElement.querySelectorAll(':scope > [inert]').forEach((el) => (el.inert = false));
trigger.focus();
}
trigger.addEventListener('click', openModal);
close.addEventListener('click', closeModal);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeModal();
});
document.addEventListener('keydown', (e) => {
if (!isOpen) return;
if (e.key === 'Escape') closeModal();
if (e.key === 'Tab') {
// Wrap focus inside the dialog.
const items = [...modal.querySelectorAll(FOCUSABLE)];
const first = items[0], last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
});
Place the overlay as a direct child of <body> so the inert step covers the rest of the page.
Class reference
| Class | Description |
|---|---|
nf-modal-overlay |
Fixed full-screen backdrop — hide with nf-hidden or aria-hidden="true" |
nf-modal |
Dialog panel — default max-width 32rem |
nf-modal-sm |
20rem |
nf-modal-lg |
48rem |
nf-modal-xl |
64rem |
nf-modal-full |
Full viewport |
nf-modal-header |
Top section with title and close button |
nf-modal-title |
Title text |
nf-modal-close |
Close × button |
nf-modal-body |
Scrollable content area |
nf-modal-footer |
Bottom action row |