JAVASCRIPT

Build a Reusable and Accessible JavaScript Modal Dialog

Discover how to programmatically create and manage an accessible modal dialog, including focus trapping, keyboard navigation (Esc key), and dynamic content injection.

class AccessibleModal {
  constructor(options = {}) {
    this.options = {
      title: 'Modal Title',
      content: 'Modal Content Here',
      onClose: () => {},
      ...options
    };
    this.modal = null;
    this.focusableElements = null;
    this.firstFocusableElement = null;
    this.lastFocusableElement = null;
    this.previouslyFocusedElement = null;

    this._createModal();
    this._bindEvents();
  }

  _createModal() {
    this.modal = document.createElement('div');
    this.modal.setAttribute('role', 'dialog');
    this.modal.setAttribute('aria-modal', 'true');
    this.modal.setAttribute('aria-labelledby', 'modal-title');
    this.modal.classList.add('js-accessible-modal');
    this.modal.innerHTML = `
          <div class="js-modal-overlay" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); display: flex; justify-content: center; align-items: center; z-index: 1000;">
            <div class="js-modal-content" style="background: white; padding: 20px; border-radius: 5px; max-width: 500px; width: 90%; box-shadow: 0 4px 8px rgba(0,0,0,0.2);">
              <h2 id="modal-title" style="margin-top: 0;">${this.options.title}</h2>
              <div class="modal-body">${this.options.content}</div>
              <button class="js-modal-close" style="float: right; margin-top: 10px; padding: 8px 15px; background-color: #f44336; color: white; border: none; border-radius: 4px; cursor: pointer;">Close</button>
            </div>
          </div>
        `;
    document.body.appendChild(this.modal);
  }

  _bindEvents() {
    this.closeButton = this.modal.querySelector('.js-modal-close');
    this.overlay = this.modal.querySelector('.js-modal-overlay');

    this.closeButton.addEventListener('click', this.close.bind(this));
    this.overlay.addEventListener('click', (event) => {
      if (event.target === this.overlay) {
        this.close();
      }
    });
    document.addEventListener('keydown', this._handleKeydown.bind(this));
  }

  _handleKeydown(event) {
    if (event.key === 'Escape') {
      this.close();
    } else if (event.key === 'Tab') {
      this._handleTabKey(event);
    }
  }

  _handleTabKey(event) {
    if (!this.focusableElements) {
        this._setupFocusTrap(); // Ensure focusable elements are identified
    }
    if (!this.focusableElements.length) {
        event.preventDefault(); // If no focusable elements, prevent tab from leaving modal
        return;
    }

    const focusedElement = document.activeElement;
    const isFirstFocusable = focusedElement === this.firstFocusableElement;
    const isLastFocusable = focusedElement === this.lastFocusableElement;

    if (event.shiftKey) { // Shift + Tab
      if (isFirstFocusable) {
        this.lastFocusableElement.focus();
        event.preventDefault();
      }
    } else { // Tab
      if (isLastFocusable) {
        this.firstFocusableElement.focus();
        event.preventDefault();
      }
    }
  }

  _setupFocusTrap() {
    this.focusableElements = Array.from(
      this.modal.querySelectorAll(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      )
    ).filter(el => !el.disabled && el.offsetParent !== null); // Filter out disabled/hidden elements
    this.firstFocusableElement = this.focusableElements[0];
    this.lastFocusableElement = this.focusableElements[this.focusableElements.length - 1];
  }

  open() {
    this.previouslyFocusedElement = document.activeElement; // Save focus
    document.body.style.overflow = 'hidden'; // Prevent scrolling body
    this.modal.style.display = 'block';

    setTimeout(() => { // Delay to ensure modal is rendered before attempting focus
      this._setupFocusTrap();
      if (this.firstFocusableElement) {
        this.firstFocusableElement.focus();
      } else {
        this.closeButton.focus(); // Fallback to close button
      }
    }, 0);
  }

  close() {
    this.modal.remove();
    document.body.style.overflow = ''; // Restore body scrolling
    document.removeEventListener('keydown', this._handleKeydown.bind(this));
    if (this.previouslyFocusedElement) {
      this.previouslyFocusedElement.focus(); // Restore focus
    }
    this.options.onClose();
  }
}

// Usage example:
// const myModal = new AccessibleModal({
//   title: 'Welcome!',
//   content: '<p>This is a custom, accessible modal dialog built with plain JavaScript.</p>',
//   onClose: () => console.log('Modal closed!')
// });
// setTimeout(() => myModal.open(), 1000); // Open after 1 second
How it works: This class provides a robust and accessible modal dialog solution. It dynamically creates all necessary modal elements, appends them to the DOM, and manages crucial accessibility features like focus trapping within the modal, keyboard navigation (Tab and Shift+Tab), and closing via the Escape key or clicking the overlay. It also prevents body scrolling while the modal is open and restores focus to the element that triggered the modal upon closing.

Need help integrating this into your project?

Our team of expert developers can help you build your custom application from scratch.

Hire DigitalCodeLabs