DEV Community

Yoriiis
Yoriiis

Posted on

Improving accessibility for popins: focus trap and keyboard handling

Keyboard users often encounter issues when interacting with popins, modals, or tooltips. During an accessibility audit with a partner, we noticed that focus can easily escape the popin, leading to a confusing experience.

This approach ensures keyboard users can stay within the popin and interact predictably, improving accessibility and user experience (UX) for all users.


The problem

Popins generally include several interactive elements: a close button, overlay, and main content such as forms or call-to-actions (CTAs). Without proper focus management, users can tab out of the popin into the underlying page, breaking accessibility rules and making interactions unpredictable.


Implementation

Key rules for accessible popins:

Initial focus on the close button

When a popin opens, we save the currently focused element and programmatically set focus to the close button using .focus(). This ensures that keyboard users start interacting with the popin immediately and allows us to restore focus later when the popin closes.

Trap focus inside the popin

Tabulation should only traverse elements inside the popin. The focus remains trapped only while the popin is open. Reaching the last element and pressing ⇥ Tab returns to the first, and ⇧ Shift + ⇥ Tab from the first moves to the last.

When the popin is closed, focus is restored to the element that triggered its opening.

If the popin opens automatically without user interaction, do not move focus; instead, ensure the popin is keyboard-accessible and optionally notify the user via an ARIA live region.

Visible focus styling

Using :focus-visible ensures keyboard users can see which element is active. This pseudo-class only applies when interacting via keyboard, avoiding unnecessary outlines for mouse users.

💡 A good practice is to allow the popin to be closed by pressing the Escape or clicking on the overlay.

Example

Here's a minimal example of a popin with HTML, CSS, and JS implementing the focus trap pattern:

HTML

<div role="dialog" aria-modal="true" aria-labelledby="newsletter-title" class="popin">
  <button type="button" class="popin-close" aria-label="Close newsletter popin">×</button>
  <h2 id="newsletter-title">Subscribe to our newsletter</h2>
  <form>
    <input type="email" placeholder="Your email" required />
    <button type="submit" class="form-submit">Subscribe</button>
  </form>
</div>
Enter fullscreen mode Exit fullscreen mode

CSS

.popin *:focus-visible {
  outline: 2px solid #007acc;
  outline-offset: 2px;
}
Enter fullscreen mode Exit fullscreen mode

JavaScript

class FocusTrap {
  constructor({ container, firstElement, lastElement }) {
    this.container = container;
    this.firstElement = firstElement;
    this.lastElement = lastElement;
    this.previousActiveElement = null;

    this.focusableSelector =
      'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';

    this.handleKeydown = this.handleKeydown.bind(this);
  }

  init() {
    this.previousActiveElement = document.activeElement;
    this.focusableContent = [...this.container.querySelectorAll(this.focusableSelector)];

    this.firstElement.focus();

    document.addEventListener('keydown', this.handleKeydown);
  }

  handleKeydown(e) {
    if (e.key !== 'Tab') return;

    // Recalculate focusable elements
    this.focusableContent = [...this.container.querySelectorAll(this.focusableSelector)];
    const last = this.lastElement ?? this.focusableContent.at(-1);

    if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      this.firstElement.focus();
    } else if (e.shiftKey && document.activeElement === this.firstElement) {
      e.preventDefault();
      last.focus();
    }
  }

  destroy() {
    document.removeEventListener('keydown', this.handleKeydown);

    this.previousActiveElement?.focus();
  }
}
Enter fullscreen mode Exit fullscreen mode

Key points

  • Always set initial focus to the most relevant interactive element (usually the close button)
  • Trap focus inside popins to prevent keyboard users from leaving the overlay unintentionally
  • Use :focus-visible to ensure the active element is clearly visible for keyboard navigation
  • Maintain correct DOM order and define tabindex for logical navigation
  • Include proper ARIA roles and attributes (role="dialog", aria-modal="true", aria-labelledby, button types)
  • Restore focus to the triggering element after closing the popin
  • Handle automatically opened popins appropriately (keyboard-accessible, ARIA live notification)
  • Modularize the focus trap pattern for reuse across multiple popins

Conclusion

Focus management for popins is a small but crucial detail in accessibility. Implementing a focus trap provides a predictable experience for keyboard users and can prevent common accessibility pitfalls.

Thinking about focus management early in your modal design ensures a smoother and more consistent experience for all users, and the pattern is reusable across multiple sites or projects.


Resources


Top comments (0)