Tips & Tricks Thursday 09: Trap Focus in Modals Code Improvements

Tips & Tricks Thursday Logo

Trap Focus in Modals and Pop-Ups

  • Focus Management: When a modal or pop-up opens, trap the focus within it until the user closes it. This prevents users from inadvertently navigating to elements behind the modal.
  • Example:
<div id="modal" role="dialog" aria-labelledby="modal-title" aria-describedby="modal-description" aria-modal="true">
  <h2 id="modal-title">Modal Title</h2>
  <p id="modal-description">This is a modal dialog.</p>
  <button id="close-modal">Close</button>
</div>

<script>
  const modal = document.getElementById('modal');
  const focusableElements = modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
  const firstFocusableElement = focusableElements[0];
  const lastFocusableElement = focusableElements[focusableElements.length - 1];

  document.addEventListener('keydown', function(event) {
    if (event.key === 'Tab') {
      if (event.shiftKey) {
        // Shift + Tab
        if (document.activeElement === firstFocusableElement) {
          event.preventDefault();
          lastFocusableElement.focus();
        }
      } else {
        // Tab
        if (document.activeElement === lastFocusableElement) {
          event.preventDefault();
          firstFocusableElement.focus();
        }
      }
    }
  });

  // Set initial focus
  firstFocusableElement.focus();
</script>

The sample code is a solid start in terms of structure, but there are a few areas where improvements can be made.

Key Areas for Improvement:

1. Handle Focus on Modal Open/Close

  • When the modal opens, you should store the element that had focus prior to the modal being opened. This ensures that when the modal is closed, focus is returned to the previous element (usually the button that triggered the modal).
  • When the modal closes, focus should be restored to that previously focused element.
  • Example:
let previouslyFocusedElement;

function openModal() {
  previouslyFocusedElement = document.activeElement; // Store the previously focused element
  modal.style.display = 'block'; // Make modal visible
  firstFocusableElement.focus(); // Focus the first focusable element inside the modal
}

function closeModal() {
  modal.style.display = 'none'; // Hide modal
  previouslyFocusedElement.focus(); // Return focus to the previously focused element
}

document.getElementById('close-modal').addEventListener('click', closeModal);

2. Handle Focus for Dynamically Loaded Content

  • If the modal contains dynamically loaded content (like form elements), you should re-query the focusableElements each time the modal opens, in case new elements are added or removed from the DOM
  • Example:
function updateFocusableElements() {
  focusableElements = modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
  firstFocusableElement = focusableElements[0];
  lastFocusableElement = focusableElements[focusableElements.length - 1];
}

// Update focusable elements each time the modal is opened
openModal = function() {
  updateFocusableElements();
  // Rest of the code
};

3. Close Modal with Escape Key

  • Users should be able to close modals using the Escape key. This is a standard accessibility practice for modals and dialogs.
  • Example:
document.addEventListener('keydown', function(event) {
  if (event.key === 'Escape') {
    closeModal(); // Close modal when 'Escape' key is pressed
  }
});

4. Ensure aria-hidden and Inert State for Background Elements

  • While the modal is open, you should ensure that the rest of the page content is inaccessible to screen readers and keyboard users. This can be done using aria-hidden="true" on the background elements or applying the inert attribute, if supported
  • Example:
const backgroundContent = document.querySelector('main'); // Assuming main contains the background content

function openModal() {
  previouslyFocusedElement = document.activeElement;
  backgroundContent.setAttribute('aria-hidden', 'true'); // Hide the background content from screen readers
  modal.style.display = 'block';
  firstFocusableElement.focus();
}

function closeModal() {
  modal.style.display = 'none';
  backgroundContent.removeAttribute('aria-hidden');
  previouslyFocusedElement.focus();
}

5. Tabindex Management and Focus Safeguarding

  • Ensure the modal itself (or its container) has tabindex="-1" so it can receive focus if needed. This ensures that users relying on assistive technologies can perceive the modal as the currently active region.
  • Example:
<div id="modal" role="dialog" aria-labelledby="modal-title" aria-describedby="modal-description" aria-modal="true" tabindex="-1">
  <!-- Modal content -->
</div>

6. Consider Focus Wrap Behavior on Elements Not Yet in DOM

  • If you have elements that are added to the DOM after the modal opens, like via AJAX or dynamic rendering, be sure to update the focus-trap logic dynamically. The focusableElements list should be recalculated whenever the content changes.

Final Example

<div id="modal" role="dialog" aria-labelledby="modal-title" aria-describedby="modal-description" aria-modal="true" tabindex="-1" style="display: none;">
  <h2 id="modal-title">Modal Title</h2>
  <p id="modal-description">This is a modal dialog.</p>
  <button id="close-modal">Close</button>
</div>

<script>
  const modal = document.getElementById('modal');
  let previouslyFocusedElement;
  let focusableElements;
  let firstFocusableElement;
  let lastFocusableElement;

  function updateFocusableElements() {
    focusableElements = modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
    firstFocusableElement = focusableElements[0];
    lastFocusableElement = focusableElements[focusableElements.length - 1];
  }

  function openModal() {
    previouslyFocusedElement = document.activeElement;
    updateFocusableElements();
    modal.style.display = 'block';
    firstFocusableElement.focus();
    document.querySelector('main').setAttribute('aria-hidden', 'true'); // Assuming 'main' contains the background content
  }

  function closeModal() {
    modal.style.display = 'none';
    document.querySelector('main').removeAttribute('aria-hidden');
    previouslyFocusedElement.focus();
  }

  document.getElementById('close-modal').addEventListener('click', closeModal);

  document.addEventListener('keydown', function(event) {
    if (event.key === 'Escape') {
      closeModal(); // Close modal on Escape
    }
    if (event.key === 'Tab') {
      if (event.shiftKey) {
        // Shift + Tab
        if (document.activeElement === firstFocusableElement) {
          event.preventDefault();
          lastFocusableElement.focus();
        }
      } else {
        // Tab
        if (document.activeElement === lastFocusableElement) {
          event.preventDefault();
          firstFocusableElement.focus();
        }
      }
    }
  });

  // Example of opening modal (could be tied to a button click event)
  openModal();
</script>

Summary of Improvements

  • Restoring focus to the previously focused element when the modal is closed.
  • Handling dynamically loaded content by updating focusable elements each time the modal opens.
  • Support for Escape key to allow users to close the modal using the keyboard.
  • aria-hidden or inert attribute for the background content while the modal is open, making sure it’s inaccessible to screen readers.
  • Tabindex and focus management to ensure that the modal can receive focus and that focus is trapped within it while it’s open.
Scroll to Top