How to create Modals with Rails and Hotwire (and Tailwind CSS)

An abstract representation of modal in a duotone colorscheme

With the introduction of Hotwire in September of 2021, Rails developers could now create more advanced 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. A few articles and howto’s have 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 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.

Create your own Stimulus modal

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

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

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

# 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 to view the modal:

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

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

Now when you click the link 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/application.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, 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:

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

Let’s up this modal with a simple Stimulus controller.

Enhance the modal with Stimulus

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

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

  hide(event) {
    event.preventDefault();

    this.element.remove();
  }

  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:

<turbo-frame id="modal">
  # …

  <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:

 <%= 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.

It works with a small Rails controller concern:

# 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:

# 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 send your suggestions or ideas over.

Get UI & Product Engineering Insights for Rails Apps (and product updates!)

Published at . Last updated at . Have suggestions or improvements on this content? Do reach out.

UI components for Ruby on Rails apps

$ 129 one-time
payment

Get Access
  • One-time Payment

  • Access to the Entire Library

  • Built using ViewComponent

  • Designed with Tailwind CSS

  • Enhanced with Hotwire