Accessible modal with focus trap
A modal dialog that traps keyboard focus, closes on Escape or backdrop click and returns focus to the trigger.
Tailwind CSSModalsmodaldialogfocus-trapa11y
Live preview
Code
<div id="app" class="p-8">
<button type="button" data-modal-open class="rounded-lg bg-indigo-600 px-4 py-2 text-white">Delete project</button>
</div>
<div id="modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-slate-900/60 p-4" hidden>
<div role="dialog" aria-modal="true" aria-labelledby="modal-title" aria-describedby="modal-desc"
class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
<h2 id="modal-title" class="text-lg font-semibold text-slate-900">Delete this project?</h2>
<p id="modal-desc" class="mt-2 text-sm text-slate-600">This removes all files and cannot be undone.</p>
<div class="mt-6 flex justify-end gap-3">
<button type="button" data-modal-close class="rounded-lg border border-slate-300 px-4 py-2 text-sm">Cancel</button>
<button type="button" data-modal-close class="rounded-lg bg-red-600 px-4 py-2 text-sm text-white">Delete</button>
</div>
</div>
</div>
const modal = document.getElementById('modal');
const app = document.getElementById('app');
const FOCUSABLE = 'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])';
let opener = null;
function open(trigger) {
opener = trigger;
modal.hidden = false;
modal.classList.replace('hidden', 'flex');
app.inert = true; // page behind is unreachable
document.body.classList.add('overflow-hidden');
modal.querySelector(FOCUSABLE).focus();
}
function close() {
modal.hidden = true;
modal.classList.replace('flex', 'hidden');
app.inert = false;
document.body.classList.remove('overflow-hidden');
opener?.focus(); // hand focus back
}
document.querySelectorAll('[data-modal-open]').forEach((b) => b.addEventListener('click', () => open(b)));
modal.querySelectorAll('[data-modal-close]').forEach((b) => b.addEventListener('click', close));
modal.addEventListener('click', (e) => { if (e.target === modal) close(); });
document.addEventListener('keydown', (e) => {
if (modal.hidden) return;
if (e.key === 'Escape') return close();
if (e.key !== 'Tab') return;
const items = [...modal.querySelectorAll(FOCUSABLE)];
const first = items[0], last = items.at(-1);
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
});
How it works
What it solves
A modal is only usable if keyboard focus cannot wander behind it and if closing it puts the user back where they were. Without those, screen reader and keyboard users get lost. This version implements the full contract without a library.
How it works
The markup is a backdrop div containing an element with role="dialog", aria-modal="true" and aria-labelledby pointing at the title. Opening stores document.activeElement, unhides the backdrop, marks the rest of the page inert, and moves focus to the first focusable control. A keydown handler intercepts Tab: when focus is on the last control it wraps to the first, and Shift Tab wraps the other way. Escape closes, and a click on the backdrop itself (not the panel) closes too. On close, the script removes inert, hides the modal and restores focus to the saved trigger. Body scroll is locked with a class while open.
Adapting it
Use the native dialog element with showModal() if you can drop older browsers; it traps focus for you. Add more buttons freely, because the trap collects focusable elements each time it runs. For forms, focus the first field instead of the first control.
Pitfalls
- Setting
inerton the wrapper must exclude the modal itself, so keep the modal outside that wrapper. - Forgetting to restore focus is the most common failure.
- Nested modals need a stack of triggers.
How to use it
- Paste
modal.htmland place your page content inside the element with idapp. - Keep the modal markup outside
#appsoinertdoes not affect it. - Load
modal.jswithdefer. - Add
data-modal-opento any trigger button.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.