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()
}
}
}
<div data-controller="modal"
data-action="keydown@window->modal#closeOnEscape">
<!-- Trigger button -->
<%= content_tag :button,
button_text,
type: "button",
class: button_class,
data: { action: "modal#open" },
aria_haspopup: "dialog" %>
<!-- Modal overlay -->
<div data-modal-target="container"
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center hidden"
data-action="click->modal#closeOnBackdrop"
aria-hidden="true">
<div data-modal-target="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
class="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 p-6">
<div class="flex items-start justify-between mb-4">
<h2 id="modal-title" class="text-xl font-bold">
<%= title %>
</h2>
<button type="button"
class="text-gray-400 hover:text-gray-600"
data-action="modal#close"
aria-label="Close dialog">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="modal-content">
<%= content %>
</div>
</div>
</div>
</div>
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.