Programming
Jorge Leites • 10 MAR 2025
Building a Turbo-Enabled Modal in Rails with View Components and Stimulus

If you're just getting started with Turbo in Rails, you might be surprised at how something as simple as creating a modal can turn into a bit of a puzzle. Modals are everywhere in modern web apps, but when you throw Turbo Frames and Stimulus into the mix, things can get tricky fast.
But don’t worry, we’re going to walk through how to build a modal component in Rails using View Components and Turbo, keeping it simple, flexible, and easy to manage.
In this blog post, we’ll create a flexible modal that:
- Works with static content,
- Dynamically loads content using Turbo Frames
- Automatically closes when a form inside it is successfully submitted.
1. Setting up the modal component
We’ll start by building the modal as a View Component. View Components help keep our UI modular, reusable, and testable—way better than dealing with bloated partials.
Here’s what our modal component looks like:
module Overlays
class ModalComponent < ViewComponent::Base
use_helpers :turbo_frame_tag
renders_one :panel
attr_reader :turbo_frame
def initialize(turbo_frame: nil)
@turbo_frame = turbo_frame
end
private
def turbo?
turbo_frame.present?
end
end
end
Key details:
- renders_one :panel: lets us define content inside the modal.
- turbo_frame: is an optional attribute we’ll use to load content dynamically into the modal.
- If no turbo_frame is passed, we’ll just render static content.
2. Defining the Modal’s HTML Structure
Next, we define the modal’s HTML layout. This includes a backdrop, a panel for the modal content, and logic to control visibility.
<div data-controller="modal" data-action="turbo:submit-end->modal#handleFormSubmit">
<%= content %>
<div class="relative z-10" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="fixed inset-0 bg-gray-500/75 transition-opacity hidden" aria-hidden="true" data-modal-target="backdrop"></div>
<div class="fixed inset-0 z-10 w-screen overflow-y-auto transform transition-all hidden" data-modal-target="panel">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div class="relative sm:my-8 sm:w-full sm:max-w-sm">
<% if turbo? %>
<%= turbo_frame_tag turbo_frame, data: { action: 'turbo:frame-load->modal#open' } %>
<% else %>
<%= panel %>
<% end %>
</div>
</div>
</div>
</div>
</div>
Explanation:
- data-action=”'turbo:frame-load->modal#open”: opens the modal when content is loaded.
- data-action="turbo:submit-end->modal#handleFormSubmit": ensures the modal closes automatically when a form is successfully submitted.
3. Writing the Stimulus Controller
Now that we have the modal structure, we need a Stimulus controller to manage the modal's behavior—specifically, how to open and close the modal smoothly.
Here’s the Stimulus controller for managing the modal:
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="modal"
export default class extends Controller {
static targets = ["backdrop", "panel"];
backdropOpenClasses = ["opacity-100", "ease-out", "duration-300"];
backdropCloseClasses = ["opacity-0", "ease-in", "duration-200"];
panelOpenClasses = [
"ease-out",
"duration-300",
"opacity-100",
"translate-y-0",
"sm:scale-100",
];
panelCloseClasses = [
"ease-in",
"duration-200",
"opacity-0",
"translate-y-4",
"sm:translate-y-0",
"sm:scale-95",
];
connect() {
this.backdropTarget.classList.add(...this.backdropCloseClasses);
this.panelTarget.classList.add(...this.panelCloseClasses);
}
open() {
this.backdropTarget.classList.remove("hidden");
this.panelTarget.classList.remove("hidden");
setTimeout(() => {
this.backdropTarget.classList.remove(...this.backdropCloseClasses);
this.backdropTarget.classList.add(...this.backdropOpenClasses);
this.panelTarget.classList.remove(...this.panelCloseClasses);
this.panelTarget.classList.add(...this.panelOpenClasses);
}, 0);
}
close() {
this.backdropTarget.classList.remove(...this.backdropOpenClasses);
this.backdropTarget.classList.add(...this.backdropCloseClasses);
this.panelTarget.classList.remove(...this.panelOpenClasses);
this.panelTarget.classList.add(...this.panelCloseClasses);
setTimeout(() => {
this.backdropTarget.classList.add("hidden");
this.panelTarget.classList.add("hidden");
}, 200);
}
handleFormSubmit(event) {
if (event.detail.success) {
this.close();
}
}
}
Key features:
- open(): This function animates the modal into view. It removes the hidden class from both the backdrop and the panel and then applies opening animations.
- close(): This hides the modal and applies closing animations.
- handleFormSubmit(): If a form is successfully submitted (via Turbo), this method automatically closes the modal. It's tied to the Turbo submit-end event, so it works seamlessly with Turbo forms.
4. Automatically closing the modal on Form Submission
The modal also integrates nicely with Turbo forms. When a form inside the modal is submitted successfully, the modal will close automatically. This is super handy for creating forms within modals—no extra work is needed to close the modal when the form is submitted successfully.
Example controller action
For this to work, we need to make sure that our controller properly responds with the right HTTP status and Turbo Stream responses. If validation fails, we need to send an error response so the modal can stay open and show validation messages.
Here’s an example of a simple create action in a Rails controller:
def create
@user = User.new(user_params)
if @user.save
# We’re using turbo streams to append the user.
# You can also redirect to reload the whole page
render turbo_stream: turbo_stream.append(:users, @user)
else
render :new, status: :unprocessable_entity
end
end
5. Using the modal
Now that we have everything set up, we can use the modal in our views. There are two main ways you can render the modal:
Using static content in the modal:
<div data-controller="modal" data-action="turbo:submit-end->modal#handleFormSubmit">
<%= content %>
<div class="relative z-10" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="fixed inset-0 bg-gray-500/75 transition-opacity hidden" aria-hidden="true" data-modal-target="backdrop"></div>
<div class="fixed inset-0 z-10 w-screen overflow-y-auto transform transition-all hidden" data-modal-target="panel">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div class="relative sm:my-8 sm:w-full sm:max-w-sm">
<% if turbo? %>
<%= turbo_frame_tag turbo_frame, data: { action: 'turbo:frame-load->modal#open' } %>
<% else %>
<%= panel %>
<% end %>
</div>
</div>
</div>
</div>
</div>
Using Turbo frames to dynamically load content:
If you want to load content dynamically into the modal using Turbo Frames, you can pass a turbo_frame option like so:
<%= render Overlays::Modal.new(turbo_frame: :new_user) %>
<%= link_to('Create user', new_user_path, data: { turbo_frame: :new_user }) %>
When you click on the link to create a user, Turbo will load the form into the modal, and once the form is submitted, the modal will close if the submission is successful.
The bottom line: Seamless modals with Turbo and Stimulus in Rails
Creating modals in Rails doesn’t have to be a headache, especially when you use Turbo, Stimulus, and View Components together. With this approach, we’ve built a reusable, dynamic modal that works seamlessly with Turbo and automatically closes when a form is successfully submitted. This pattern can save you a lot of time when you need to display forms or other content in modals, all while keeping your code clean and maintainable.
If you want to dive deeper into Rails and more, visit our blog for the latest trends.