webdevpuneet.com

Accessible Modal Primitive — Real Keyboard Focus Trap and Focus Restoration

Accessible Modal Primitive with Real Focus Trap · Modals · Plain HTML, CSS & JS · Live preview

CategoryModals

Copy & paste

Source Code

<div class="demo">
  <p class="hint">Try tabbing through the page — focus never escapes the modal while it's open, and returns to this button when closed.</p>
  <button class="trigger" id="openBtn">Open modal</button>

  <div class="overlay" id="overlay">
    <div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle" id="modal">
      <h2 id="modalTitle">Accessible modal</h2>
      <p>Every focusable element below is reachable by Tab, and focus wraps from the last one back to the first — it never lands on the page behind this dialog.</p>

      <div class="modal-fields">
        <input type="text" placeholder="First field" />
        <input type="text" placeholder="Second field" />
        <a href="#" id="modalLink">A focusable link</a>
      </div>

      <div class="modal-actions">
        <button class="btn ghost" id="cancelBtn">Cancel</button>
        <button class="btn primary" id="confirmBtn">Confirm</button>
      </div>
    </div>
  </div>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

Genuine focus trap — Tab and Shift+Tab both wrap correctly at the modal's actual focusable boundaries
Only intervenes at the trap boundaries, leaving all normal in-modal Tab navigation completely untouched
Focus restoration returns keyboard focus to the exact element that had it before opening, not just "the page"
Focus is moved into the modal immediately on open, so screen reader users are correctly informed a dialog appeared
Focusable elements are recomputed live on every check, correctly handling dynamically added or removed content
Filters out focusable-but-invisible elements via offsetParent, avoiding a trap that includes hidden elements
Escape key and click-outside-to-close both correctly trigger the same accurate focus restoration path
role="dialog" aria-modal="true" with aria-labelledby for correct assistive technology announcement

About this UI Snippet

Modal Primitive with a Real Focus Trap — Correct, Not Just Visual

Screenshot of the Accessible Modal Primitive with Real Focus Trap snippet rendered live

Countless modal implementations get the *visual* overlay right but skip the accessibility mechanics entirely — a sighted mouse user never notices, but a keyboard user tabbing through the page can end up focused on an element visually hidden behind the modal, or lose their place entirely once the modal closes. This snippet implements the two specific behaviors that matter most: a genuine focus trap while open, and accurate focus restoration on close.

Computing "what's focusable right now," not a static list

getFocusableElements() queries the modal for a standard set of naturally-focusable tags and attributes, then filters with .offsetParent !== null — a reliable way to exclude elements that are present in the DOM but not actually visible (display: none, or otherwise not rendered). This list is recomputed fresh every time it's needed rather than cached once, so it stays correct even if the modal's content changes while it's open.

The trap itself: intercepting Tab only at the boundaries

handleKeydown's Tab-handling logic doesn't try to intercept every Tab press — only the two boundary cases that actually matter: Shift+Tab while focus is on the *first* focusable element (which should wrap to the *last*), and plain Tab while focus is on the *last* element (which should wrap to the *first*). e.preventDefault() stops the browser's native tab order from taking over exactly at those two moments; every other Tab press inside the modal is left completely alone, moving focus normally between the modal's own elements — the trap only ever intervenes at the edges.

Two-way focus restoration, not just "closing the modal"

openModal() explicitly records document.activeElement into lastFocusedBeforeOpen *before* moving focus into the modal — capturing exactly what had focus at the moment the modal opened, not assuming it was necessarily the trigger button (it could have been reached via keyboard from anywhere). closeModal() then calls .focus() on that specific stored element, restoring the user's exact keyboard position rather than leaving focus on document.body or wherever the modal's own DOM removal happens to leave it — this is what lets a keyboard user continue exactly where they left off after dismissing the dialog.

Moving focus into the modal immediately on open

The instant the modal opens, focus is moved to its first focusable element ((focusables[0] || modal).focus()) rather than left on the now-visually-hidden trigger button behind the overlay — without this, a screen reader user would have no indication the modal even opened, since their reading position wouldn't have moved at all.

Step by step

How to Use

  1. 1
    Open the modal and press Tab repeatedlyFocus cycles through every focusable element inside the modal and wraps from the last back to the first — it never reaches the trigger button or page behind it.
  2. 2
    Press Shift+Tab from the first elementFocus wraps backward to the last focusable element in the modal, completing the trap in both directions.
  3. 3
    Close the modal via Cancel, Confirm, Escape, or clicking outsideIn every case, keyboard focus returns to exactly the element that had it before the modal opened.
  4. 4
    Add or remove focusable content inside the modalgetFocusableElements() re-queries the modal fresh every time it's needed, so new inputs, links or buttons are automatically included in the trap with no additional wiring.
  5. 5
    Reuse this primitive for any modal contentSwap the fields/buttons inside .modal for your own content — the trap, Escape handling, and focus restoration logic are entirely content-agnostic.

Real-world uses

Common Use Cases

A11Y
Accessible Modal Foundation
A correct base to build any modal, dialog, or overlay on top of, rather than reimplementing focus handling each time.
Form Dialogs and Confirmations
Any modal containing form fields benefits from a real focus trap keeping keyboard input contained correctly.
Design System Modal Primitive
A reference implementation to base a design system's modal component on, ensuring accessibility is correct by default.
COMPLIANCE
Accessibility-Compliant Products
Meet WCAG focus-management requirements for modal dialogs without hand-rolling the logic per modal.
Related: Long-Press Preview (iOS-Style Peek)
See the Long-Press Preview (iOS-Style Peek) for a related modals pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

Escaping means Tab or Shift+Tab moving focus to an element outside the modal — typically something in the page behind the overlay. The handler intercepts exactly the two boundary presses (Shift+Tab on the first element, Tab on the last) with preventDefault() and manually moves focus back to the opposite end, so the browser's native tab order never gets the chance to move focus past the modal's own elements.

The element that had focus right before the modal opened might not always be the button that triggered it — for example, focus could have been elsewhere and the modal opened programmatically. Capturing the real document.activeElement at that moment guarantees focus is restored to wherever the user actually was, not an assumption about which element triggered the modal.

getFocusableElements() re-queries the modal's DOM every single time it's called (on open, and on every Tab keydown), rather than caching a list once — so any dynamically added focusable element is automatically included in the trap boundaries immediately, with no extra code needed.

An element can match the focusable-selector query (e.g. a button) while still being invisible due to display:none somewhere in its ancestor chain. offsetParent is null for elements that aren't actually rendered, so filtering on it excludes hidden-but-technically-focusable elements from the trap's boundary calculation.

Yes — openModal() calls .focus() on the modal's first focusable element (or the modal container itself if none exist) immediately after showing it, ensuring both keyboard and screen reader users are correctly directed into the dialog's content right away rather than being left on the now-obscured trigger.

Yes — both paths call the same closeModal() function, which always performs the identical focus-restoration behavior (returning focus to the element stored in lastFocusedBeforeOpen), so the accessible behavior doesn't vary depending on how the modal was dismissed.