Accessible modal dialogs with Stimulus and ARIA

Jordan Lee Jan 2026
2 tabs
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["container", "dialog"]

  connect() {
    this.previousActiveElement = null
  }

  open() {
    this.previousActiveElement = document.activeElement

    this.containerTarget.classList.remove('hidden')
    document.body.style.overflow = 'hidden'

    // Focus the first focusable element in the modal
    const firstFocusable = this.dialogTarget.querySelector(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )
    if (firstFocusable) firstFocusable.focus()

    // Trap focus
    document.addEventListener('focusin', this.trapFocus.bind(this))
  }

  close() {
    this.containerTarget.classList.add('hidden')
    document.body.style.overflow = ''

    // Return focus to trigger element
    if (this.previousActiveElement) {
      this.previousActiveElement.focus()
    }

    document.removeEventListener('focusin', this.trapFocus.bind(this))
  }

  trapFocus(event) {
    if (!this.dialogTarget.contains(event.target)) {
      event.preventDefault()
      const firstFocusable = this.dialogTarget.querySelector(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      )
      if (firstFocusable) firstFocusable.focus()
    }
  }

  closeOnEscape(event) {
    if (event.key === 'Escape') {
      this.close()
    }
  }

  closeOnBackdrop(event) {
    if (event.target === this.containerTarget) {
      this.close()
    }
  }
}
2 files · javascript, erb Explain with highlit

Modals are everywhere but often fail accessibility requirements. I build modals with Stimulus that properly manage focus, support keyboard navigation, and announce themselves to screen readers. When opened, focus moves to the modal and gets trapped inside using focusin events. Escape key closes the modal and returns focus to the trigger element. ARIA attributes like role='dialog', aria-modal='true', and aria-labelledby provide semantic meaning for assistive technology. I also prevent body scroll when the modal is open and provide both click-outside and explicit close button dismissal. This pattern ensures modals work for all users regardless of how they interact with the page.