Programming

Jorge Leites • 10 MAR 2025

Building a Turbo-Enabled Modal in Rails with View Components and Stimulus

post cover picture

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:

  1. Works with static content,
  2. Dynamically loads content using Turbo Frames
  3. 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:

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:

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:

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.

Stay updated!

project background