Fancy Form Modals with Rails + Turbo

Making form modals that look great using Rails, Turbo, Stimulus, and Tailwind.

If you want to stick a form in a modal, and you want it to look real nice and animated, and have the following pieces of technology in your application:

  • Rails
  • Turbo
  • StimulusJS
  • Tailwind

then this is the blog post for you.

Prior Art: I came across a handful of helpful blog posts and discussions for making modals work with Rails and Turbo: example 1, example 2. They were great for getting my bearings around the initial setup, but they stopped short when I wanted to really polish the modal experience. Specifically: smooth transitions for the modal opening & closing, displaying errors inline, and gracefully closing the modal on successful form submission.

Basic Setup

First, the frames. Turbo Frames are the backbone of the architecture here, and we use two of them to achieve a polished modal experience. The first one is the "wrapper" frame which holds the modal itself with all it's faded background goodness.

This is in our application.html.erb file:

<%= turbo_frame_tag "modal" %>

Then on any page, we can plop a link in which will populate the modal with the contents of a GET request. Such as:

<%= link_to "Edit", edit_user_path, data: { "turbo-frame": "modal" } %>

The response for edit_user_path is parsed for a modal frame tag, and the contents of that are dropped into the standing frame defined in our layout. Let's take a look at how we render that response:

// app/views/users/edit.html.erb

<%= render "shared/modal" do %>
  <%= simple_form_for current_user do |f| %>
    <%= f.input :name %>

    <%= f.button :submit %>
  <% end %>
<% end %>

This leans on a shared modal partial (so any form can easily be modal-ized). Here's a cut down version of that shared partial:

// app/views/shared/_modal.html.erb

<%= turbo_frame_tag "modal" do %>
  <div data-controller="modal" data-action="keyup@document->modal#handleKeyup">
    <!-- Clicking outside the modal will cause it to close -->
    <div class="modal-background" data-action="click->modal#close"></div>

    <div class="modal-styles-and-stuff">
      <button data-action="modal#close">X</button>

      <%= turbo_frame_tag "modal-body" do %>
        <%= yield %>
      <% end %>
    </div>
  </div>
<% end %>

Breaking this down, we see: - The turbo_frame_tag at the top, that's used to match with the frame tag defined in the application layout. Everything inside of that tag will be plopped into the existing "modal" frame tag. - data-controller and data-action attributes will kick off a StimulusJS controller. - A second turbo_frame_tag buried in here for modal-body. This is important, we'll get to it when we talk about handling form submissions. - yield so we can render whatever we want inside of a standardized modal. In this case, it's the user form.

And to wrap up the basic setup, here's the stimulus controller:

// app/javascript/controllers/modal_controller.js

import { Controller } from "stimulus"

export default class extends Controller {
  connect() {
    document.addEventListener('turbo:submit-end', this.handleSubmit)
  }

  disconnect() {
    document.removeEventListener('turbo:submit-end', this.handleSubmit)
  }

  close() {
    // Remove the modal element so it doesn't blanket the screen
    this.element.remove()

    // Remove src reference from parent frame element
    // Without this, turbo won't re-open the modal on subsequent clicks
    this.element.closest("turbo-frame").src = undefined
  }

  handleKeyup(e) {
    if (e.code == "Escape") {
      this.close()
    }
  }

  handleSubmit = (e) => {
    if (e.detail.success) {
      this.close()
    }
  }
}

This controller does a few things: 1. Sets up a submit handler so the modal closes itself when a form post is successful 2. Gives us the ability to close the modal. This is triggered by clicking on the dedicated close button, or clicking anywhere outside the modal, or just pressing the escape key.

Commentary: I like writing Stimulus controllers, it feels very easy to stay focused on the interaction pieces I'm trying to make shine, and the tooling around targets and actions go surprisingly far towards making that work straightforward.

That should get us started on the frontend, now let's dig into handling form responses.

Handling Form Submissions

Let's just dive straight into the controller code, since it's the source of how form submissions are handled.

# app/controllers/users_controller.rb

def update
  if current_user.update(user_params)
    respond_to do |format|
      format.turbo_stream do
        render turbo_stream: [
          turbo_stream.update("flash", partial: "shared/flash", locals: { notice: "Profile updated" }),
          turbo_stream.update("user-about", partial: "users/about", locals: { user: current_user })
        ]
      end

      format.html do
        redirect_to current_user, notice: "Profile updated"
      end
    end
  else
    render :edit, status: :unprocessable_entity
  end
end

A couple of interesting things going on here.

1. If the form post is a success, we send two Turbo Stream updates back.

One to update the flash message, and another to update the section of the page where the form data will have an impact. This assumes you have something pretty close to this in your markup.

// app/views/layouts/application.html.erb
<%= turbo_frame_tag "flash" do %>
  <%= render "shared/flash" %>
<% end %>

// app/views/users/show.html.erb
<%= turbo_frame_tag "user-about" do %>
  <%= render "users/about", user: @user %>
<% end %>

Commentary: I find myself writing a lot of view code like this with - a frame tag wrapping a partial. With those in place, updating those on demand from any form post is as easy as: turbo_stream.update("user-about", partial: "users/about", locals: { user: current_user }). The same HTML is used for the initial render as it is the re-render, and the plumbing to make it work is very low-effort. ❤

2. The else condition.

No matter how the user is hitting this action (with JS or not), we will return the same rendered HTML in both cases.

If JS breaks and the request comes in as a plain-old HTML request, the response will work like it has worked on Rails for the last decade. This is the fallback, not something we expect to happen that often, and not that interesting.

If it's Turbo-ized, neat things happen. The browser will scan the response for a modal-body frame, and the contents of that frame will be used to swap out the existing contents on the page. This is due to the form itself being wrapped in a modal-body frame. Turbo is greedy about HTML responses when links/forms trip them within a frame, and handle the response within that frame's context. In this case, that's to our benefit since we want a re-render of the form's content, but we don't want to reload the whole page or wipe the open modal.

That means that even though the edit.html.erb file will render an entirely new HTML document, just the modal-body part in the middle will be plucked out and used in the re-render. Our first implementation here just used the modal frame itself, but that meant the whole modal was flickering off-on for every submission. Wrapping the thing that's changing (the form) in the smallest frame possible (modal-body) was the move.

PS. That status: :unprocessable_entity (or status: 422) is necessary in Turbo land, otherwise nothing happens.

Eased Modal Transitions

If you're following along, you should have modals that pop up on demand, close when you want them to, handle form submissions by re-rendering the modal with errors, or by closing the modal and re-rendering the updated content on the underlying page. But no one likes it when modals just pop up like it's the 1990s, the people deserve a pleasantly smooth transition. And for that, we're reaching for Tailwind UI.

Assuming you have Tailwind CSS up and running on your application, swing over to the Tailwind docs to grab some code for their open source modal component: https://tailwindui.com/components/application-ui/overlays/modals (they charge money for the source code of many components, but not modals, as of June 2021).

Our modal partial ends up a bit lengthier:

// app/views/shared/_modal.html.erb

<%= turbo_frame_tag "modal" do %>
  <div
    data-controller="modal"
    data-action="keyup@document->modal#handleKeyup"
    class="fixed z-10 inset-0 overflow-y-auto"
    aria-labelledby="modal-title"
    role="dialog"
    aria-modal="true"
  >
    <div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
      <div
        data-action="click->modal#close"
        data-modal-target="wrapper"
        data-transition-enter="ease-out duration-300"
        data-transition-enter-start="opacity-0"
        data-transition-enter-end="opacity-100"
        data-transition-leave="ease-in duration-200"
        data-transition-leave-start="opacity-100"
        data-transition-leave-end="opacity-0"
        class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
        aria-hidden="true"
      >
      </div>

      <!-- This element is to trick the browser into centering the modal contents. -->
      <span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>

      <div
        data-modal-target="body"
        data-transition-enter="ease-out duration-300"
        data-transition-enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
        data-transition-enter-end="opacity-100 translate-y-0 sm:scale-100"
        data-transition-leave="ease-in duration-200"
        data-transition-leave-start="opacity-100 translate-y-0 sm:scale-100"
        data-transition-leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
        class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"
      >
        <div>
          <div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-ui-red sm:mx-0 sm:h-10 sm:w-10">
            <button data-action="modal#close">X</button>
          </div>

          <%= turbo_frame_tag "modal-body" do %>
            <%= yield %>
          <% end %>
        </div>
      </div>
    </div>
  </div>
<% end %>

And our Stimulus controller gets some new functionality as well:

// app/javascript/controllers/modal_controller.js

import { Controller } from "stimulus"
import { enter, leave } from "el-transition"

export default class extends Controller {
  static targets = ["wrapper", "body"]

  connect() {
    enter(this.wrapperTarget)
    enter(this.bodyTarget)

    document.addEventListener('turbo:submit-end', this.handleSubmit)
  }

  disconnect() {
    document.removeEventListener('turbo:submit-end', this.handleSubmit)
  }

  close() {
    leave(this.wrapperTarget)
    leave(this.bodyTarget).then(() => {
      // Remove the modal element after the fade out so it doesn't blanket the screen
      this.element.remove()
    })

    // Remove src reference from parent frame element
    // Without this, turbo won't re-open the modal on subsequent clicks
    this.element.closest("turbo-frame").src = undefined
  }

  handleKeyup(e) {
    if (e.code == "Escape") {
      this.close()
    }
  }

  handleSubmit = (e) => {
    if (e.detail.success) {
      this.close()
    }
  }
}

And voila, now you have modals gently easing in and out thanks almost entirely to Tailwind's good documentation and a handy package called el-transition to run the CSS class dance to trip some nice animations.

Wrapping up

Turbo and Tailwind have been a real joy to work with, and this is one of the best examples of why. I couldn't believe how quickly we had a proof-of-concept for modals working, and then how it didn't take much more to get the level of polish that we've all come to expect in this modern age of the web. I dare say, we are in the future now.

Eli Fatsi

Eli uses his mathematics degree from Carnegie Mellon to blur the lines between the digital and physical worlds. He codes for Shure, Volunteers of America, and other clients from our Boulder, CO, office.

More articles by Eli

Sign up for The Viget Newsletter

Nobody likes popups, so we waited until now to recommend our newsletter, a curated periodical featuring thoughts, opinions, and tools for building a better digital world. Read the current issue.