Thanks to visit codestin.com
Credit goes to mattbrictson.com

rails

Build a modal form with Rails, Turbo, and the dialog element

I’ll explain the differences and benefits of Turbo Streams vs Turbo Frames in this HTML-first, test-driven tutorial. Only one line of JavaScript needed!

Let’s test-drive

I find that writing a simple test up front is a useful way to think through the user experience and document a to-do list for the implementation. Let’s write a quick system test to describe the behavior we want to see.

require "application_system_test_case"

class UsersTest < ApplicationSystemTestCase
  test "create valid user via modal" do
    visit "users"
    click_link "New user"

    within "dialog" do # (1) Modal opens
      fill_in "Name", with: "Matt"
      click_button "Create User"
      assert_text "Email can't be blank" # (2) Still in the modal

      fill_in "Email", with: "[email protected]"
      click_button "Create User"
    end

    assert_no_selector "dialog" # (3) Modal closes on success
    assert_text "User was successfully created." # Flash message
  end
end

The key takeaways here are:

  1. Clicking “New user” opens a modal.
  2. Submitting an invalid form re-renders it within the modal.
  3. On success, the modal closes.

With the test in place, let’s start building!

Scaffold the user pages

Turbo is great because it allows us to enhance the server-rendered behavior that Rails already excels at. We can build our feature in the traditional way, using familiar Rails techniques: model, route, controller, and view. Once the basics are in place, we can layer Turbo on top.

I’m using a blank Rails 7.2 app as my starting point, which comes with Turbo preinstalled:

rails new turbo_modal --skip-jbuilder
jbuilder adds a bunch of JSON routes to the controller scaffold that are a distraction for the purposes of this tutorial, so I’ve omitted it.

First, let’s scaffold out a user:

bin/rails generate scaffold user name:string email:string
bin/rails db:migrate

Then add some model validations to make things more interesting:

class User < ApplicationRecord
  normalizes :email, with: ->(email) { email.strip.downcase }

  validates :name, :email, presence: true
  validates :email, format: { with: URI::MailTo::EMAIL_REGEXP, allow_blank: true }
  validates :email, uniqueness: true
end

And just like that, we have a working app! 😎

Screen recording of scaffolded users flow

Now let’s make this interaction shine with a Turbo-driven modal.

Wrap it in a <dialog>

Putting the user form inside a modal is surprisingly simple, because browsers already provide a built-in <dialog> element coupled with a showModal function. Let’s wrap the users/new.html.erb view template in a dialog:

<% content_for :title, "New user" %>

<dialog id="new_user_dialog">
  <h1>New user</h1>
  <%= render "form", user: @user %>
</dialog>

<script type="text/javascript">
  new_user_dialog.showModal();
</script>
Browsers hide <dialog> by default, until showModal is called. This is the only line of JavaScript we’ll need for this tutorial.

Done! Except… the rest of the page has disappeared.

Screen recording of a full-page modal
It looks like a modal, but it’s still a separate page.

That’s because a full-page navigation happens when “New user” is clicked, so the /users/new page completely replaces the previous one.

We need a way to add the /users/new content to the existing page without replacing it. That’s where Turbo Frames comes into play.

Target a Turbo frame

To place our modal on top of the users page instead of replacing it, we need a frame to put it in. Let’s add one to users/index.html.erb and tell link_to to use it:

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

<%= turbo_frame_tag "modal" %>

Turbo’s JavaScript will now intercept “New user” clicks and load the resulting /users/new content into the Turbo frame named “modal”. This only works if /users/new is also wrapped in a Turbo frame with the same ID. Otherwise we’ll get the dreaded “content missing” error in the browser console.

Error: The response (200) did not contain the expected <turbo-frame id="modal"> and will be ignored.

Let’s fix that by updating users/new.html.erb so that the contents are wrapped in the expected <turbo-frame>:

<% content_for :title, "New user" %>

<%= turbo_frame_tag "modal" do %>
  <dialog id="new_user_dialog" open>
    <h1>New user</h1>
    <%= render "form", user: @user %>
  </dialog>

  <script type="text/javascript">
    new_user_dialog.showModal();
  </script>
<% end %>

We’re getting close! The modal appears on top of the users page, but submitting the form doesn’t quite work yet.

Screen recording of form submit error
Submitting the form now results in “content missing.”

Close the modal on submit

By default, when inside a Turbo frame, clicking a link or submitting a form will render the resulting content inside the same frame. That’s good general behavior, but it isn’t what we want for our modal frame.

When the form is successfully submitted, we want the modal frame to go away and control to return to the top-level page. To put it another way: we want the form submission to break out of the frame. This is actually pretty straightforward if we use the special _top value for the Turbo frame target.

Let’s update the form element in users/_form.html.erb to tell Turbo to break out of the frame when the form is submitted:

<%= form_with(model: user, data: { turbo_frame: "_top" }) do |form| %>

Great! Our “happy path” is working!

Screen recording of
The form submission “breaks out” of the modal and renders the subsequent page and flash message.

Unfortunately turbo_frame: "_top" is a very blunt instrument. Now all form submissions will break out of the modal frame, including unsuccessful ones with validation errors.

We need a way to target _top for successful submissions and target modal when there are validation errors, but that’s beyond the capabilities of Turbo navigation. To solve this we have to bring in a new set of tools: Turbo Streams.

Conditionally re-render with Turbo Streams

When I first encountered Turbo, I was confused by Turbo Streams and how they differ from Turbo Frames. Here’s how I now understand the concepts:

Turbo Frames are declarative: we add data attributes to our HTML tags, then Turbo takes over and magically modifies the navigation behavior of our app. Turbo Streams, on the other hand, are imperative: we issue explicit actions in Ruby code using helper methods provided by the turbo-rails gem, which Turbo then translates into JavaScript-driven updates to the browser DOM.

In the case of solving the happy-path-vs-validation-errors scenario, the declarative nature of Turbo Frames is too basic, but the imperative approach of Turbo Streams is a good fit: if the form submission has validation errors, then render the response differently.

Apply a DOM ID

First, to conditionally re-render part of the page, we’ll need to target a specific DOM ID. Let’s add id=user_form to the <form> element:

<%= form_with(model: user, id: "user_form", data: { turbo_frame: "_top" }) do |form| %>

Respond with a Turbo Stream

If there are validation errors, we want user_form to be re-rendered in place. We can do that using the turbo_stream.replace action in users_controller.rb, like this:

if @user.save
  redirect_to @user, notice: "User was successfully created."
else
  respond_to do |format|
    # This is the default scaffold behavior to support non-Turbo requests
    format.html { render :new, status: :unprocessable_entity }

    # For a Turbo-driven request, re-render the form
    format.turbo_stream do
      render turbo_stream: turbo_stream.replace(
        "user_form",
        partial: "users/form",
        locals: { user: @user }
      )
    end
  end
end

Are we done?

Screen recording of completed modal
Validation errors are now re-rendered within the modal.

To make sure, let’s check the system test we wrote at the outset:

$ bin/rails test test/system/users_test.rb --verbose
Running 1 tests in a single process (parallelization threshold is 50)
Run options: --verbose --seed 44431

# Running:

UsersTest#test_create_valid_user_via_modal = 2.13 s = .

Finished in 2.137665s, 0.4678 runs/s, 1.4034 assertions/s.
1 runs, 3 assertions, 0 failures, 0 errors, 0 skips

All assertions succeed and the test passed. We did it!

What’s next?

When I use this technique in real apps, I extract the showModal one-liner into a Stimulus controller in charge of handling modal-related JS behavior. This Stimulus controller also implements an action for a close button, to make the modal more accessible. For brevity, I’ve omitted this detail (and other things like database constraints and unit tests) from this tutorial.

Progressive enhancement

Also, where possible, I strongly believe Turbo/Stimulus behavior should progressively enhance the underlying server-rendered experience. As it stands, our modal doesn’t work without JavaScript, which is not ideal.

One solution is to extract the modal wrapper into a separate layout, which we can use conditionally based on whether Turbo is being used or not:

<%= turbo_frame_tag "modal" do %>
  <dialog id="dialog">
    <%= yield %>
  </dialog>

  <script type="text/javascript">
    dialog.showModal();
  </script>
<% end %>
app/views/layouts/modal.html.erb
The contents of the modal are injected by yield (i.e. the contents of our users/new.html.erb view template).

With the modal-specific part extracted, our new.html.erb template reverts to the plain scaffold code:

<% content_for :title, "New user" %>

<h1>New user</h1>
<%= render "form", user: @user %>

In the controller, we can then conditionally apply the modal layout or application layout based on whether the request is targeting the modal Turbo Frame:1

def new
  @user = User.new
  layout = turbo_frame_request_id == "modal" ? "modal" : "application"
  render "new", layout:
end

This works! Our system test still passes, and now with JavaScript disabled, users will get the modal-less scaffold experience.

CSS bonus round

Finally, let’s address what’s painfully obvious: the modal we made is pretty ugly! Luckily, since it’s built with standard HTML we can easily apply whatever styling solution we want. Here is some vanilla CSS that gets the job done:

* {
  box-sizing: border-box;
}

html {
  font-family: system-ui;
}

input {
  font-size: inherit;
}

dialog {
  border: none;
  border-radius: 12px;
  box-shadow: 0 4px 8px rgb(0 0 0 / 25%);
  padding: 28px;
  width: min(85vw, 48ch);

  h1 {
    font-size: 1.5em;
    font-weight: 600;
    margin-block: 0 40px;
  }

  label {
    margin-block: 20px 8px;
  }

  input[type="text"],
  input[type="submit"] {
    border-radius: 6px;
    padding: 12px;
    width: 100%;
  }

  input[type="text"] {
    border: 1px solid lightgray;
  }

  input[type="submit"] {
    appearance: none;
    background: royalblue;
    border: none;
    color: white;
    cursor: pointer;
    font-weight: 600;
    margin-top: 32px;
  }
}
Browsers understand CSS nesting now; no Sass required!

And the result:

Modal with CSS applied

Conclusion

I hope this walk-through illustrated how to practically apply Turbo Frames and Turbo Streams using a gradual, layered approach on top of standard Rails scaffolding. There are many ways to accomplish modals in Rails, but this approach feels the most “Rails-y” to me. Thanks for reading!


  1. Thank you Radan Skorić for introducing me to the turbo-frame header technique

Share this? Copy link

Feedback? Email me!

Hi! 👋 I’m Matt Brictson, a software engineer in San Francisco. This site is my excuse to practice UI design, fuss over CSS, and share my interest in open source. I blog about Rails, design patterns, and other development topics.

Recent articles

RSS
View all posts →

Open source projects

mattbrictson/bundle_update_interactive

A stylish interactive mode for Bundler, inspired by yarn upgrade-interactive

237
Updated 10 days ago

mattbrictson/nextgen

Generate your next Rails app interactively! This template includes production-ready recommendations for testing, security, developer productivity, and modern frontends. Plus optional Vite support! ⚡️

370
Updated 10 days ago

More on GitHub →