Async Modal on Rails with Native <dialog> Element
In this post, I'll show you how to create asynchronous modal windows in Rails using Turbo Frames and the native <dialog>
element. This approach combines the power of Rails' Hotwire stack with modern web standards to create smooth, accessible modal experiences without heavy JavaScript frameworks. We'll build a complete example using a login form that loads dynamically and displays in a native dialog with proper focus management and backdrop handling.
Step 1: Add Container to Layout
Add a turbo-frame container for modals in your main layout:
<body>
<!-- ...existing code... -->
<%= turbo_frame_tag :modal %>
</body>
Step 2: Configure Link to Open Modal
Create a link that will load content into our modal frame:
<%= link_to "Sign In", login_path, data: { turbo_frame: :modal } %>
On click, the content will be loaded asynchronously and injected into the turbo_frame_tag :modal
.
Step 3: Wrap Form in turbo_frame
In the login form view, wrap the content in the corresponding turbo-frame:
<%= turbo_frame_tag :modal do %>
<dialog data-controller="modal" class="modal">
<!-- We need one more turbo_frame_tag to prevent the modal from hiding and showing again when validation occurs or when navigating to another page inside the modal -->
<%= turbo_frame_tag :modalContent do %>
<div class="modal__header">
<h3 class="modal__title">Sign In</h3>
<button type="button" class="modal__close" data-action="click->modal#close">
<%= vite_icon_tag "close.svg", class: "size-6" %>
<span class="sr-only">Close Modal</span>
</button>
</div>
<%= form_with url: rodauth.login_path, method: :post, class: "form" do |form| %>
<div class="modal__body">
<%= form.email_field rodauth.login_param, autofocus: true, required: true %>
<%= form.password_field rodauth.password_param, required: true %>
</div>
<div class="modal__footer">
<%= f.submit "Sign In" %>
</div>
<% end %>
<% end %>
</dialog>
<% end %>
Step 4: Create Stimulus Controller
Now create a controller that will manage the modal window:
import { Controller } from '@hotwired/stimulus'
import { stimulus } from '~/init'
export default class ModalController extends Controller {
connect () {
this.element.addEventListener('click', this.#closeOnBackdropClick.bind(this))
this.element.showModal()
}
disconnect () {
this.element.removeEventListener('click', this.#closeOnBackdropClick.bind(this))
this.close()
}
show () {
this.element.showModal()
}
close () {
try {
this.element.close()
ModalController.turboFrame.src = null
this.element.remove()
} catch (e) {}
}
#closeOnBackdropClick (event) {
if (event.target === this.element) {
this.close()
}
}
static get turboFrame () {
return document.querySelector('turbo-frame[id=\'modal\']')
}
}
stimulus.register('modal', ModalController)
How It Works
On link click, Turbo sends an request and loads content into
turbo_frame_tag :modal
As soon as content is loaded, the Stimulus controller's
connect()
method fires, which: Opens the dialog viashowModal()
and adds event listener for backdrop click closingWhen closing the modal (
close()
method): Closes the dialog. Important! Clears the turbo-framesrc
and removes the element to prevent flashing of old content when opening the next modalWhen controller disconnects (
disconnect()
): Removes all event listeners and closes the modal
Benefits of This Approach
✅ Uses native
<dialog>
element✅ Asynchronous content loading
✅ Automatic focus management and accessibility
✅ Simple integration with Rails and Turbo
✅ Doesn't pollute DOM when closed
Additional Notes
For more complex cases, I recommend using ViewComponent - it allows you to create reusable modal components with clear structure and better code organization.
The key insight here is that the modal opens automatically when content loads (via connect()
) and cleans up after itself when closed (via clearing the turbo-frame), preventing any visual artifacts when opening different modals.
Additional Resources
📁 Complete Modal Component Code - Full implementation with styles and tests
🚀 JetRockets UI Components - Collection of reusable Rails UI components