DEV3 Technologies home

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>

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 inert on 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

  1. Paste modal.html and place your page content inside the element with id app.
  2. Keep the modal markup outside #app so inert does not affect it.
  3. Load modal.js with defer.
  4. Add data-modal-open to 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.

Related snippets