How to create modals with Rails and Hotwire (and Tailwind CSS)
Tags: rails, hotwire, stimulus, tailwindcss
Published: 2024-02-01 16:00:00

With the [introduction of Hotwire](https://world.hey.com/dhh/stimulus-3-turbo-7-hotwire-1-0-9d507133) in September of 2021, Rails developers could now create more advanced, JavaScript-based UI's without writing hardly any JavaScript.

One of the most common UI elements in modern web-apps is the modal. Creating it with Hotwire is indeed quite straightforward, but there are few gotchas and some things to keep in mind. There have been a few articles and howto's been written about it already, but I'd like to explore a few more (novel) ideas here.

In essence all a modal, sometimes also called a “dialog” is, is a contained component laid on top of the rest of the app (as a side-note: there's a [`dialog` element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog) that can take care of most of the tricky bits too).

So this means with Rails and Hotwire we need two things:

1. a turbo-frame to load the modals
2. a wrapper around the actual modal content


## The basics

Let's add the turbo-frame to your application's layout.

```erb
# app/views/layouts/application.htm.erb
<​%= turbo_frame_tag "modal" %>
```

Then wrap the view you want as a modal like this:

```erb
# app/views/users/new.html.erb

<turbo-frame id="modal">
  # Modal wrapper
  <div role="dialog" tabindex="-1" class="fixed inset-0 z-30 flex items-center justify-center w-full h-screen p-2">

    # Backdrop
    <div class="fixed inset-0 block w-full h-screen cursor-default bg-gray-900/30"></div>

    <div class="relative z-20 w-full max-w-2xl max-h-screen overflow-y-auto bg-white shadow-lg rounded-md">
      # Modal's content, eg. a `form_with` helper
    </div>
  </div>
</turbo-frame>
```

Then a way to view the modal:

```erb
# app/views/users/index.html.erb

<​%= link_to "Create new user", new_user_path, data: {turbo_frame: "modal"} %>
```

Now when you click the button above, the content from `app/views/users.new.html.erb` between the `turbo-frame` tag gets rendered in the `turbo_frame_tag "modal"` added to the `app/views/layouts.html.erb`. And because of the Tailwind CSS classes added, the content will “lay on top” of the app.

And that are the basics of getting a modal working in Rails app with Hotwire.

✨ Tip: for a quick way to get more advanced modal's (including Stimulus), check out the [modal from Rails Designer](https://railsdesigner.com/components/modals/), built with ViewComponent, designed with Tailwind CSS and enhanced with Hotwire.


## Beyond the basics

Well, those basics were easy enough, but if you opened the modal, you can't close it now… That's bad. We can improve this in few simple ways.

Let's change the backdrop-div to be a `button_to`, like so:

```erb
<​%= button_to nil, nil, type: :button, method: :get, data: {turbo_frame: "modal"}, class: "fixed inset-0 block w-full h-screen cursor-default bg-gray-900/30" %>
```

While this works, it's not good practice (you can expect the developer console for the reason). Let's up this modal with a simple Stimulus controller.


## Enhance the modal with Stimulus

```js
# app/javascript/controllers/modal_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  connect() {
    this.element.focus();
  }

  hide() {
    this.#modalTurboFrame.src = null;
  }

  hideOnSubmit(event) {
    if (event.detail.success) {
      this.hide();
    }
  }

  disconnect() {
    this.#modalTurboFrame.src = null;
  }

  // private

  get #modalTurboFrame() {
    return document.querySelector("turbo-frame[id='modal']");
  }
}
```

Then update your `app/views/users/new.html.erb` like so:

```erb
<turbo-frame id="modal">
  < %= tag.button type: :button, data: {action: "modal#hide"}, class: "fixed inset-0 block w-full h-screen cursor-default bg-gray-900/30" %>

  <div data-controller="modal" data-action="turbo:submit-end->modal#hideOnSubmit keydown.esc->modal#hide" role="dialog" tabindex="-1" class="fixed inset-0 z-30 flex items-center justify-center w-full h-screen p-2">
    # …
  </div>
</turbo-frame>
```

All this Stimulus controller really does is reset the turbo-frame's src attribute, when…

- …clicking on the backdrop;
- …a form is successfully submitted;
- …pressing the Escape key.

And focus the element on `connect()`; possible because of the `tabindex`-attribute on the element.

If you want you can create a button inside the modal's content to _cancel_ (something you often see in modals, next to confirmation/save button), like so:

```erb
 <​%= button_tag "Cancel", type: :button, method: :get, data: {action: "modal#hide"}, class: "px-3 py-1 text-sm leading-6 font-medium text-gray-700 bg-white border border-gray-200 rounded-md hover:border-gray-300" %>
```

Notice how it's just a regular Rails `button_tag`, with a data-action attribute with the value of `modal#hide`. That's the beauty of Stimulus, small, reusable JavaScript for the HTML you have.


## Force a view in a modal

Another thing I'd like to add is force views to be viewed only as a modal (and as a standalone page, like “users/new.html.erb”). This is a tip taken from a previous [article about ViewComponents](https://dev.to/railsdesigner/tips-and-ideas-for-viewcomponent-with-tailwind-css-and-hotwire-5g43).

It works with a small Rails controller concern:

```rb
# app/controllers/concerns/frameable.rb

module Frameable
  extend ActiveSupport::Concern

  private

  def ensure_turbo_frame_response
    redirect_to root_path unless turbo_frame_request?
  end

  def production_environment?
    Rails.env.production?
  end
end
```

And now over at the UsersController, add this:

```rb
# app/controllers/users_controller.rb

class UsersController < ApplicationController
  before_action :ensure_turbo_frame_response, only: %w[new], if: :production_environment?
end
```

This will ensure users can only view the users/new view through a turbo-frame, ie. as a modal. But only when in production (`if: :production_environment?`). I like to add this flag  because designing the view is quicker standalone than within the modal.

And that's all there's to it to get basic modals working in your Rails app. Feel free to leave your suggestions or ideas below.