How to create an accessible modal with native JavaScript in WordPress

Contents

Introduction — Why build an accessible modal with native JavaScript

Modals are common UI patterns on WordPress sites: dialogs for forms, image galleries, cookie notices and confirmations. But modals are also frequent accessibility traps. Screen reader users, keyboard-only users and people with slow motion preferences can be blocked or confused by naive implementations. This tutorial gives a complete, production-ready approach to building an accessible modal using only native JavaScript and CSS, with progressive enhancement for WordPress. It covers ARIA, focus management, keyboard handling, background inertness, scroll locking, animations respectful of reduced motion, and how to integrate the code into a WordPress theme or plugin.

Accessibility goals and constraints

  • Semantic role: The modal must expose role=dialog and be labelled and described so assistive technologies know what changed.
  • Focus: When the modal opens, focus must move into the modal. The Tab key must be trapped inside the modal. When the modal closes, focus must return to the element that opened it.
  • Background inertness: Content behind the modal must be made inert so screen readers and keyboard navigation dont interact with it while the modal is open.
  • Keyboard: Esc closes the modal. Enter activates controls. Tab/Shift Tab cycles focus inside.
  • Animations: Respect the users prefers-reduced-motion
  • Progressive enhancement: If JavaScript is disabled, content degrades gracefully (for example, a link to a separate ARIA-compliant page or a non-modal inline fallback).

High-level approach

  1. Provide lightweight HTML markup for the modal and an activator (trigger).
  2. Style the modal and overlay using CSS and add a class like .is-open to toggle visibility.
  3. Use JavaScript to:
    1. Open/close the modal and add/remove the .is-open class.
    2. Apply ARIA attributes (role, aria-modal, aria-hidden management for siblings).
    3. Trap focus inside the modal.
    4. Return focus to the trigger element on close.
    5. Lock page scroll while modal is open.
  4. Integrate into WordPress by enqueueing assets and optionally injecting the modal markup in the footer.

Example markup (HTML)

Place this markup into a template part or inject it via a footer action. The example is minimal and assumes JavaScript will control the aria-hidden state and visibility.






CSS — visual styles, hiding and animations

The CSS below provides a basic overlay, centers the dialog, toggles visibility via the [hidden] attribute, respects reduced motion, and avoids relying on the HTML dialog element for broader browser support.

/ Basic reset for the modal when hidden /
.modal[hidden] {
  display: none
}

/ Overlay covers the viewport /
.modal__overlay {
  position: fixed
  inset: 0
  background: rgba(0,0,0,0.5)
  z-index: 9998
}

/ Dialog container centered /
.modal__dialog {
  position: fixed
  top: 50%
  left: 50%
  transform: translate(-50%, -50%)
  z-index: 9999
  background: #fff
  max-width: 90vw
  max-height: 90vh
  overflow: auto
  border-radius: 6px
  box-shadow: 0 10px 30px rgba(0,0,0,0.3)
  padding: 1rem
}

/ Simple animation remove for reduced motion /
@media (prefers-reduced-motion: no-preference) {
  .modal__dialog {
    transition: transform 180ms ease-out, opacity 180ms ease-out
    opacity: 0
    transform: translate(-50%, -45%) scale(0.98)
  }
  .modal[aria-hidden=false] .modal__dialog {
    opacity: 1
    transform: translate(-50%, -50%) scale(1)
  }
}

/ When modal is open set aria-hidden false and show /
.modal[aria-hidden=false] {
  display: block
}

JavaScript — accessible behavior

Below is a robust, framework-free implementation. It:

  • Finds triggers with [data-modal-target].
  • Opens the modal identified by the trigger value.
  • Stores the opener element to return focus when the modal closes.
  • Traps focus by computing tabbable elements and intercepting Tab and Shift Tab.
  • Supports closing via overlay click, Close buttons, and Esc key.
  • Attempts to make rest of page inert: uses the inert attribute if available, otherwise falls back to setting aria-hidden on sibling nodes.
  • Prevents background scrolling by setting inline body styles and preserves scroll position.
/
  accessible-modal.js
  Native JS accessible modal controller
/
(function () {
  use strict

  // Selectors for focusable elements
  var FOCUSABLE_SELECTORS = [
    a[href],
    area[href],
    input:not([disabled]):not([type=hidden]),
    select:not([disabled]),
    textarea:not([disabled]),
    button:not([disabled]),
    iframe,
    object,
    embed,
    [contenteditable],
    [tabindex]:not([tabindex^=-])
  ].join(,)

  // Utilities
  function isHidden(el) {
    return el.hasAttribute(hidden)  el.getAttribute(aria-hidden) === true  window.getComputedStyle(el).display === none
  }

  function getFocusableElements(container) {
    return Array.prototype.slice.call(container.querySelectorAll(FOCUSABLE_SELECTORS))
      .filter(function (el) {
        return el.offsetWidth > 0  el.offsetHeight > 0  el.getClientRects().length
      })
  }

  // Inert management: try native inert, otherwise set aria-hidden on siblings.
  function setInertForSiblings(element, inert) {
    var rootChildren = Array.prototype.slice.call(document.body.children)
    if (inert in HTMLElement.prototype) {
      rootChildren.forEach(function (child) {
        if (child !== element  !child.contains(element)) {
          child.inert = inert
        }
      })
    } else {
      rootChildren.forEach(function (child) {
        if (child !== element  !child.contains(element)) {
          if (inert) {
            child.setAttribute(aria-hidden, true)
          } else {
            child.removeAttribute(aria-hidden)
          }
        }
      })
    }
  }

  // Scroll lock
  var scrollLock = {
    locked: false,
    scrollTop: 0,
    lock: function () {
      if (this.locked) return
      this.scrollTop = window.pageYOffset  document.documentElement.scrollTop
      document.documentElement.style.top = -   this.scrollTop   px
      document.documentElement.style.position = fixed
      this.locked = true
    },
    unlock: function () {
      if (!this.locked) return
      document.documentElement.style.position = 
      document.documentElement.style.top = 
      window.scrollTo(0, this.scrollTop)
      this.locked = false
    }
  }

  // Focus trap handler factory
  function createTrap(modal, onClose, opener) {
    var focusable = getFocusableElements(modal)
    var first = focusable[0]
    var last = focusable[focusable.length - 1]

    // If no focusable elements, ensure dialog container gets focus
    if (!first) {
      modal.focus()
    } else {
      first.focus()
    }

    function handleKeydown(e) {
      if (e.key === Escape  e.key === Esc) {
        e.preventDefault()
        onClose()
      } else if (e.key === Tab) {
        // Update list in case DOM changed
        focusable = getFocusableElements(modal)
        first = focusable[0]
        last = focusable[focusable.length - 1]

        if (!focusable.length) {
          e.preventDefault()
          return
        }

        if (!e.shiftKey  document.activeElement === last) {
          e.preventDefault()
          first.focus()
        } else if (e.shiftKey  document.activeElement === first) {
          e.preventDefault()
          last.focus()
        }
      }
    }

    document.addEventListener(keydown, handleKeydown)
    return function removeTrap() {
      document.removeEventListener(keydown, handleKeydown)
    }
  }

  // Open modal
  function openModal(modal, opener) {
    if (!modal) return
    modal.removeAttribute(hidden)
    modal.setAttribute(aria-hidden, false)

    // Make background inert
    setInertForSiblings(modal, true)

    // Scroll lock
    scrollLock.lock()

    // Save opener to restore focus later
    modal._opener = opener  document.activeElement

    // Trap focus
    var removeTrap = createTrap(modal, function() { closeModal(modal) }, modal._opener)
    modal._removeTrap = removeTrap

    // Click handlers: close on overlay or elements with data-modal-close
    modal.addEventListener(click, modal._overlayClickHandler = function (e) {
      if (e.target  e.target.hasAttribute(data-modal-overlay)) {
        closeModal(modal)
      }
    })

    Array.prototype.forEach.call(modal.querySelectorAll([data-modal-close]), function (btn) {
      btn.addEventListener(click, btn._closeHandler = function (e) {
        e.preventDefault()
        closeModal(modal)
      })
    })
  }

  // Close modal
  function closeModal(modal) {
    if (!modal) return
    modal.setAttribute(aria-hidden, true)
    modal.setAttribute(hidden, )

    // Remove inert
    setInertForSiblings(modal, false)

    // Restore scroll
    scrollLock.unlock()

    // Remove trap and handlers
    if (modal._removeTrap) {
      modal._removeTrap()
      modal._removeTrap = null
    }
    if (modal._overlayClickHandler) {
      modal.removeEventListener(click, modal._overlayClickHandler)
      modal._overlayClickHandler = null
    }
    Array.prototype.forEach.call(modal.querySelectorAll([data-modal-close]), function (btn) {
      if (btn._closeHandler) {
        btn.removeEventListener(click, btn._closeHandler)
        btn._closeHandler = null
      }
    })

    // Restore focus to the opener
    try {
      if (modal._opener  typeof modal._opener.focus === function) {
        modal._opener.focus()
      }
    } catch (e) {
      // ignore focus errors
    }
    modal._opener = null
  }

  // Initialization: wire up triggers
  function initAccessibleModals(context) {
    context = context  document
    var triggers = context.querySelectorAll([data-modal-target])
    Array.prototype.forEach.call(triggers, function (trigger) {
      trigger.addEventListener(click, function (e) {
        e.preventDefault()
        var id = trigger.getAttribute(data-modal-target)
        var modal = document.getElementById(id)
        if (!modal) return
        openModal(modal, trigger)
      })
    })

    // Close on global Esc for any open modal (defensive)
    document.addEventListener(keydown, function (e) {
      if (e.key === Escape  e.key === Esc) {
        var openModalEl = document.querySelector(.modal[aria-hidden=false])
        if (openModalEl) {
          e.preventDefault()
          closeModal(openModalEl)
        }
      }
    })
  }

  // Auto-init on DOMContentLoaded
  if (document.readyState === loading) {
    document.addEventListener(DOMContentLoaded, function () {
      initAccessibleModals(document)
    })
  } else {
    initAccessibleModals(document)
  }

  // Expose API if someone wants to control modals programmatically
  window.AccessibleModal = {
    open: openModal,
    close: closeModal,
    init: initAccessibleModals
  }
})()

ARIA attributes and why each matters

  • role=dialog — tells assistive tech this is a dialog window.
  • aria-modal=true — signals that content outside the dialog is inert while its open.
  • aria-labelledby — points to the element that contains the dialog title. Screen readers announce this when the dialog receives focus.
  • aria-describedby — provides a longer description or summary of the dialog content.
  • aria-hidden — used on the modal container and optionally on page siblings to toggle visibility to AT users.
  • tabindex=-1 on the dialog container — allows programmatic focus when there are no focusable children.
  • aria-controls / aria-haspopup on the trigger — optional but helps document the relationship.

Making the background inert

There is an experimental inert attribute that prevents user interaction and removes elements from the accessibility tree. If supported, prefer setting element.inert = true on siblings of the modal. The script above attempts inert first and falls back to setting aria-hidden=true on sibling elements for screen readers. That fallback has caveats (e.g., elements with their own aria-live regions) so test carefully with screen readers.

Scroll locking

When the modal opens, prevent the underlying page from scrolling and remember the scroll position so it can be restored on close. The sample uses a simple approach that fixes the document position. Alternatives include applying overflow: hidden to lthtmlgt or ltbodygt but be aware of potential layout shifts and scrollbar reflow. Test on mobile devices and with various CSS frameworks.

Progressive enhancement and non-JS fallback

If JavaScript is disabled, ensure the trigger can still navigate the user to an accessible alternative (for example, a dedicated form page). One pattern is to make the trigger a link to a fallback URL, and JavaScript enhances it into an in-page modal by intercepting clicks. Example:


Contact us

WordPress: enqueue the scripts and styles

Add the JavaScript and CSS to your theme or plugin and enqueue them properly so they load in the footer and support concatenation and cache busting.

// functions.php or plugin main file
function mytheme_enqueue_accessible_modal_assets() {
  wp_enqueue_style( accessible-modal-css, get_template_directory_uri() . /css/accessible-modal.css, array(), 1.0 )
  wp_enqueue_script( accessible-modal-js, get_template_directory_uri() . /js/accessible-modal.js, array(), 1.0, true )
}
add_action( wp_enqueue_scripts, mytheme_enqueue_accessible_modal_assets )

WordPress: printing modal markup in the footer

To avoid duplicating modal markup in templates, you can print a modal skeleton in the footer and populate its content dynamically or use a template part. Example shows a simple modal container printed in the footer via an action.

function mytheme_print_modal_markup() {
  // Minimal HTML skeleton for the modal
  ?>
  
  

Testing checklist (manual automated)

  • Keyboard: Open modal with the keyboard, navigate all controls with Tab/Shift Tab, close with Esc, ensure focus returns to opener.
  • Screen reader: Test with NVDA/JAWS on Windows and VoiceOver on macOS/iOS. When the modal opens, the dialog title should be announced and background content should not be navigable.
  • Reduced motion: If prefers-reduced-motion is set, there should be no animation or minimized animation for the dialog show/hide.
  • Mobile: Ensure scroll lock works and the dialog fits on small screens (use max-height and overflow:auto within dialog).
  • Inert fallback: Verify that non-inert fallback (aria-hidden on siblings) doesnt hide important live regions that should remain accessible.
  • Automated accessibility: Run axe-core or similar checks in dev tools to catch common ARIA mistakes.

Common pitfalls and how to avoid them

  • Not trapping focus: Keyboard users can tab into the page behind the modal. Use a robust focus trap as shown.
  • Not returning focus: When the modal closes, always restore focus to the opener to avoid disorientation.
  • Hiding content visually but not from AT: Using only CSS visibility can leave content visible to screen readers. Use aria-hidden or inert appropriately.
  • Relying on the ltdialoggt element without a polyfill: ltdialoggt has useful semantics but limited browser support and may need polyfills to behave consistently.
  • Animations that disrupt users: Honor prefers-reduced-motion and dont trap users in long animations.

Advanced topics and enhancements

  • Focus stacking: If your UI can have nested modals, maintain a stack of open modals and only restore inertness and focus when the stack unwinds.
  • Accessible announcements: For dynamic content loaded into the modal (AJAX), consider announcing changes with an aria-live region inside the modal.
  • ARIA roles for different dialog types: Use role=alertdialog for urgent messages requiring immediate attention ensure the keyboard behavior matches expectations.
  • Modal size variants: Provide CSS utility classes for small/large modals and responsive adjustments.
  • Testing automation: Integrate axe-core into CI to catch regressions.

Summary: key implementation checklist

  1. Provide semantic markup with role=dialog and aria-labelledby/aria-describedby.
  2. On open: set aria-hidden=false, set page siblings inert/aria-hidden, lock scroll, move focus into modal, attach focus trap and handlers.
  3. On close: remove aria-hidden or set to true, remove inert on siblings, unlock scroll, remove event handlers, restore focus to opener.
  4. Respect reduced motion and handle non-JS fallbacks with a reasonable alternative or link target.
  5. Enqueue assets correctly in WordPress, and optionally render modal markup in the footer with a hook.

Final notes

This tutorial provides a practical, native-JavaScript solution for accessible modals tailored for WordPress usage. Copy the HTML/CSS/JS to your theme or plugin, adapt class names to your CSS architecture, and thoroughly test with keyboard and screen readers. The code samples keep dependencies to zero and show how to integrate by enqueueing scripts and printing markup in the footer for easy reuse across templates.



Acepto donaciones de BAT's mediante el navegador Brave :)



Leave a Reply

Your email address will not be published. Required fields are marked *