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()
}
}
}
<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>
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.
Related snips
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Semantic HTML Example</title>
Semantic HTML5 elements and accessibility best practices
html
html5
semantics
by Alex Chang
2 tabs
erb
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
rails
hotwire
stimulus
by Henry Kim
2 tabs
javascript
import { Controller } from "@hotwired/stimulus"
import Mousetrap from "mousetrap"
export default class extends Controller {
connect() {
// Global shortcuts
Keyboard shortcuts with Stimulus and Mousetrap
stimulus
javascript
ux
by Jordan Lee
2 tabs
javascript
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
rails
hotwire
stimulus
by codesnips
4 tabs
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
html
forms
validation
by Alex Chang
1 tab
javascript
import { Application } from "@hotwired/stimulus"
import FormSubmitController from "./controllers/form_submit_controller"
const application = Application.start()
application.debug = false
Disable submit button while Turbo form is submitting
rails
hotwire
stimulus
by codesnips
3 tabs
Share this code
Here's the card — post it anywhere.