How to build modals with Hotwire (Turbo Frames + StimulusJS)

At Cycode we use a lot of modals. They allow our users to create/edit/destroy records throughout the app, without leaving the current page. This is pretty common in most modern webapps. Here’s one in action:

This is really handy from a UX point of view and gives your app a Single-Page-App feel. We use Hotwire’s mix of Turbo Frames and stimulus—more on that in a bit—to achieve this, but that isn’t always the case in many apps. The pre-Hotwire approach to displaying modals with Rails would involve either:

A) Modals hidden within CSS and using JS to show/hide the content. Example: Bootstrap modals

B) The “Rails AJAX approach” of using a link_to with remote: true that would direct a controller to respond with a .js.erb template, that would update a DOM with some HTML or render a rails template. Something like this:

# any view

<%= link_to "show modal", new_post_path, remote: true %>
# app/controllers/posts_controller.rb
def new_post
@post = Post.new
respond_to do |format|
  format.js
end
// app/views/posts/new.js.erb
document.getElementById("new-post").innerHTML = "<%= j render "posts/new", post: @post %>";

By the way, here’s a nice example of an app built using this approach: CRUD AJAX

But that’s not what we do at Bearer:

  • The Hidden CSS approach doesn’t fit our needs, because we use Tailwind instead of Bootstrap. We would also have to render a bunch of hidden content that may never be seen. This adds extra burden on the client, and just doesn’t feel right.
  • The server-side .js.erb approach never really became popular in production applications, and since the release of Hotwire turbo it became obsolete.

Hotwire allows us to add Single Page App (Turbo Frames), or AJAX (Turbo Streams) functionality without writing any custom Javascript! (It has all been pre-written for us).

In this post we’re going to build a Rails 7 application that allows us to render modals while leveraging all the best bits of CSS, Javascript (Stimulus JS) and Turbo (Frames).

Initial app setup

Create a new app with postgresql and (optionally) tailwind pre-configured by the gem tailwindcss-rails:

rails new hotwire-modal -d postgresql -c tailwind

Next, let’s scaffold out a post, create the db, migrate it, and compile the initial Tailwind css.

rails g scaffold post title body:text --no-helper --no-assets --no-controller-specs --no-view-specs --no-test-framework --no-jbuilder
rails db:create
rails db:migrate
rails tailwindcss:build
./bin/dev

Don’t forget that last command to compile Tailwind, as described in the tailwindcss-rails docs.

Note: as we are running Tailwind CSS, we should purge our classes. This removes all the unused Tailwind code and slims down our final css size. Whenever we add/remove a class, we would have to either manually run rails tailwindcss:build. You can also run bin/rails tailwindcss:watch in a console window to have it constantly watch for changes. When we created a new Rails app with Tailwind, it auto-generated a Procfile.dev file. Now, instead of running rails s you can run ./bin/dev to both start the server and watch tailwind!

If you’re following along, I’ll include links to commits along the way. Here’s the first one:Git commit: scaffold posts

Next, let’s add some basic validation to our fields:

# app/models/post.rb
  validates :title, presence: true, uniqueness: true

It’s also a good idea to define a root path:

# config/routes.rb
  root "posts#index"

At this point, the app should look like this:

Follow-along commit: Git commit: basic validations.

Now that we have a working demo app, it’s time to set up Turbo Frames.

Turbo Frames: Server-side rendered content

Add a turbo_frame_tag to your layout so that you can target it from anywhere in your application:

# app/views/layouts/application.html.erb
++<%= turbo_frame_tag "modal" %>
  <%= yield %>

Now wrap new.html.erb with a turbo_frame_tag:

# app/views/posts/new.html.erb
++<%= turbo_frame_tag "modal" do %>
  <%= render "form", post: @post %>
++<% end %>

This way, whenever you send a GET request to the URL posts/new with the data-turbo-frame=”modal” attribute, instead of doing a full-page redirect, the request will fetch the content within the turbo_frame_tag inside posts/new.html.erb and render it within turbo_frame_tag “modal” in your layout on your current page.

So in order to send such a “remote” request, you can modify your link_to by adding data: { turbo_frame: ‘modal’ }:

# app/views/posts/index.html.erb
--<%= link_to "New post", new_post_path %>
++<%= link_to "New post", new_post_path, data: { turbo_frame: 'modal' } %>

Now, when you click “New post” you will render the posts/new.html.erb within the <%= turbo_frame_tag “modal” %> in your layout file:

Let’s see what happens behind the scenes and inspect our browser behavior:

When you click “New post”, the <%= turbo_frame_tag “modal” %> will be given the attribute src=”localhost:3000/posts/new”, and the HTML of posts/new.html.erb will be rendered within:

If you open the Network tab, you will see that Turbo made a GET request to posts/new and fetched only the content within turbo_frame_tag ‘modal’, because that is what we requested in our link_to:

Turbo does the job of replacing the content of turbo_frame_tag ‘modal’ with the HTML from the response.

Yeah, Rails is integrating more closely with the front-end, without writing any javascript 🤗.

Follow-along commit: Git commit: new post modal.

Now, in a similar way, you can open the edit action in the modal:

Wrap edit.html.erb into turbo_frame_tag:

# app/views/posts/edit.html.erb
++<%= turbo_frame_tag "modal" do %>
  <%= render "form", post: @post %>
++<% end %>
# app/views/posts/_post.html.erb
--<%= link_to "Edit this post", edit_post_path(post) %>
++<%= link_to "Edit this post", edit_post_path(post), data: { turbo_frame: 'modal' } %>

So the edit modal will look like this:

Now the edit functionality works the same way as the create. Git commit: edit post modal

CSS: Style the modal

But a modal should overlay site content, not move it, right? Otherwise it’s a rather ineloquent slide-in panel.

You could combine Tailwind’s classes, but for now let’s write some custom CSS to take care of it quickly. We’ll come back with Tailwind later.

/* app/assets/application.css */
#modal {
  position: absolute;
  z-index: 2;
  right: 10px;
  top: 10px;
  width: 400px;
  word-break: break-word;
  border-radius: 6px;
  background: #bad5ff;
}
  • Quick CSS tip: position: fixed; stays on the same place when page scrolls, whereas position: absolute; – scrolls down with page.
  • z-index: 2; will place on top of the page content.

It’s not perfect, but it no longer interferes with the rest of the page.

Still following along? Here: Git commit: modal css

StimulusJS: Close modal

Usually a modal would have a Close/Cancel button or other way to escape. You might be tempted to “close” a modal by having a link_to to an url without a matching turbo_frame_tag, but this would give you a console error: Response has no matching <turbo-frame id=”modal”> element:

A better way would be to add a button that would close the modal. This is where we can bring in StimulusJS.

First, generate a controller:

bin/rails generate stimulus turbo-modal

Next, add an action that would remove the current element:

// app/javascript/controllers/turbo_modal_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  hideModal() {
    this.element.parentElement.removeAttribute("src") // it might be nice to also remove the modal SRC
    this.element.remove()
  }
}

We want to initialize the stimulus controller exclusively when a view inside a modal is rendered, and add the Close button inside. We can do this by adding the button the the modal html:

Voila! We’ve got a beautiful button to remove the modal:

Here’s the commit: Git commit: Stimulus Button – Close modal.

P.S. You can do the same in app/views/posts/edit.html.erb, or—spoilers for what’s to come—wrap the functionality for both into a reusable component.

Looks great! Let’s keep going?

TailwindCSS modal styling

Earlier we wrote some custom CSS for the modal, but we already have Tailwind installed, so why not use it? First, remove the custom CSS class that we’ve added in application.css. Next, you can apply some Tailwind classes to the div inside a rendered modal. This should do it “p-5 bg-slate-300 absolute z-10 top-10 right-10 rounded-md w-96 break-words”:

# app/views/posts/new.html.erb
<%= turbo_frame_tag "modal" do %>
  <%= tag.div data: { controller: "turbo-modal" }, style: "padding: 30px;" do %>
    New post
    <%= render "form", post: @post %>
    <%= button_tag "Close", data: { action: "turbo-modal#hideModal" }, type: "button", class: "rounded-lg py-3 px-5 bg-red-600 text-white" %>
  <% end %>
<% end %>

Again, you can do the same in app/views/posts/edit.html.erb. The modal should now look like this:

Here’s the updates: Git commit: Tailwind CSS modal.

ViewComponent: Reusable modal

So now we have A LOT of repeated HTML in app/views/posts/new.html.erb and app/views/posts/edit.html.erb. We would have to add it again in any views that we will want to render inside a modal. ViewComponent to the rescue!

Install the gem and generate a component

bundle add view_component
bin/rails generate component TurboModal title

Add the following inside the .rb file, so that you can use turbo_frame_tag ‘modal’ instead of <turbo-frame id=”modal”>:

# app/components/turbo_modal_component.rb
  include Turbo::FramesHelper

Now move the repeated HTML into the component:

# app/components/turbo_modal_component.html.erb
<%= turbo_frame_tag "modal" do %>
  <%= tag.div data: { controller: "turbo-modal" }, class: "p-5 bg-slate-300 absolute z-10 top-10 right-10 rounded-md w-96 break-words" do %>
    <%= @title %>
    <%= content %>
    <%= button_tag "Close", data: { action: "turbo-modal#hideModal" }, type: "button", class: "rounded-lg py-3 px-5 bg-red-600 text-white" %>
  <% end %>
<% end %>

Next, wrap new.html.erb and edit.html.erb into the TurboModalComponent:

<%= render TurboModalComponent.new(title: "New Post!") do %>
  <%= render "form", post: @post %>
<% end %>

That’s a much cleaner approach to prevent duplicate HTML! Great, now we can create and edit Posts inside modals thanks to Turbo Frames!

Git commit: view component turbo modal

Turbo Streams: update posts/index without page refresh

Our form works, but ideally you would display the updated/created Post on posts/index without a page refresh. That would be a perfect thing to do with Turbo Streams. Let’s break down the code for a stream:

format.turbo_stream { render turbo_stream: turbo_stream.prepend('posts', partial: 'posts/post', locals: {post: @post}) }

The above code will find the element with DOM ID=posts on the current page and prepend the partial file (_post.html.erb) to it using the data from @post. Here’s what the full snippet looks like:

# app/controllers/posts_controller.rb
  def create
    @post = Post.new(post_params)
    respond_to do |format|
      if @post.save
        format.turbo_stream { render turbo_stream: turbo_stream.prepend('posts', partial: 'posts/post', locals: {post: @post}) }
        format.html { redirect_to post_url(@post), notice: "Post was successfully created." }
      end
    end
  end

  def update
    respond_to do |format|
      if @post.update(post_params)
        format.turbo_stream { render turbo_stream: turbo_stream.replace(@post, partial: "posts/post", locals: {post: @post}) }
        format.html { redirect_to post_url(@post), notice: "Post was successfully updated." }
      end
    end
  end

It should look like this:

Git commit: turbo_streams to update current page

On create, the post is added on top of the list, and on edit, the post is updated. There’s still one problem with our implementation The modal doesn’t disappear. Let’s take care of that.

StimulusJS: handle form submissions

If you comment out the format.turbo_stream in the posts_controller and try to submit the form again, you’ll find a familiar error in the console: Response has no matching <turbo-frame id=”modal”> element. The modal nicely disappears, but we get a console error. This happens, because on a successful response, the controller tries to redirect with a GET request to posts/show.html.erb, butt that page has no turbo_frame_tag ‘modal’. We need a way to correctly handle successful form submissions with Turbo. Luckily, that’s quite easy to do, thanks to turbo events. Specifically, turbo:submit-end can tell us if a form submission is a success or a failure. If it is a success, we can close the modal. Let’s add a submitEnd action to the Stimulus controller:

// app/javascript/controllers/turbo_modal_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  hideModal() {
    this.element.parentElement.removeAttribute("src")
    this.element.remove()
  }

  // hide modal on successful form submission
  // action: "turbo:submit-end->turbo-modal#submitEnd"
  submitEnd(e) {
    if (e.detail.success) {
      this.hideModal()
    }
  }
}

And add a trigger action:

# app/components/turbo_modal_component.html.erb
<%= tag.div data: { controller: "turbo-modal", action: "turbo:submit-end->turbo-modal#submitEnd" } do %>

Now we can correctly handle form submission behavior in a Turbo Stream Modal!

Git commit: stimulus – handle turbo form submission

StimulusJS: Close modal with ESC, close when clicked outside modal

So far we can close the modal with the button, and it closes on form submission, but there’s still room to improve. You might want to add a few more common modal behaviors to your app, like:

Here is how we can handle closeWithKeyboard and closeBackground in a Stimulus controller:

// app/javascript/controllers/turbo_modal_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["modal"]

  // hide modal
  // action: "turbo-modal#hideModal"
  hideModal() {
    this.element.parentElement.removeAttribute("src")
    // Remove src reference from parent frame element
    // Without this, turbo won't re-open the modal on subsequent click
    this.modalTarget.remove()
  }

  // hide modal on successful form submission
  // action: "turbo:submit-end->turbo-modal#submitEnd"
  submitEnd(e) {
    if (e.detail.success) {
      this.hideModal()
    }
  }

  // hide modal when clicking ESC
  // action: "keyup@window->turbo-modal#closeWithKeyboard"
  closeWithKeyboard(e) {
    if (e.code == "Escape") {
      this.hideModal()
    }
  }

  // hide modal when clicking outside of modal
  // action: "click@window->turbo-modal#closeBackground"
  closeBackground(e) {
    if (e && this.modalTarget.contains(e.target)) {
      return
    }
    this.hideModal()
  }
}

With the actions created, now we need to add them to the modal component:

# app/components/turbo_modal_component.html.erb
<%= turbo_frame_tag "modal" do %>
  <%= tag.div data: { controller: "turbo-modal",
                      turbo_modal_target: "modal",
                      action: "turbo:submit-end->turbo-modal#submitEnd keyup@window->turbo-modal#closeWithKeyboard click@window->turbo-modal#closeBackground" },
                      class: "p-5 bg-slate-300 absolute z-10 top-10 right-10 rounded-md w-96 break-words" do %>
    <%= @title %>
    <%= content %>
    <%= button_tag "Close", data: { action: "turbo-modal#hideModal" }, type: "button", class: "rounded-lg py-3 px-5 bg-red-600 text-white" %>
  <% end %>
<% end %>

Now, you will be able to also close the modal by clicking ESC or clicking on the background!

Git commit: close modal on click background or ESC

Bonus: controller actions that respond only to a turbo_frame

At the moment, you can open posts#new or posts#edit in a new tab:

Considering our modal design, that does not look right. You might want these views to be available only in a modal. Only via a turbo_frame:

# app/controllers/posts_controller.rb
  before_action :ensure_frame_response, only: [:new, :edit]

  private

  def ensure_frame_response
    return unless Rails.env.development?
    redirect_to root_path unless turbo_frame_request?
  end

This way, whenever someone tries to open new or edit in a new tab, he will be redirected.

As an additional measure of security, you can replace link_to with button_to, because button_to does not have an “Open in a new Tab” option:

--<%= link_to 'New post', new_post_path, data: { turbo_frame: 'modal' } %>
++<%= button_to 'New post', new_post_path, method: :get, data: { turbo_frame: 'modal' } %>

With that in mind, be sure to avoid replacing a link with a button unless it makes sense, semantically, to do so.

Git commit: controller actions that respond only to a turbo_frame

Final thoughts

We’re done! That may seem like a lot, but in the end we’ve built the beginning of a portal modal component. The mix of Rails 7, Hotwire, ViewComponents, TailwindCSS, and Stimulus provide fantastic synergy for interactions like this. To recap:

  • Frames can be used for basic behaviors where parts of the page have their own navigation and actions.
  • Streams can be used for situations where frames aren’t enough, such as multiple parts of a page changing based on external data—like an AJAX call.

In our case above, Turbo Frames are superior to Streams for managing the form behavior, as we don’t need to worry about any error rendering behavior in the controller.

Here’s the full Git repo with final result. Interested in more on Ruby and Hotwire? Our engineering team has more content coming soon!