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 :modalAs 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-framesrcand 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
🚀 JetRockets UI Components - Collection of reusable Rails UI components