Andy from Webcrunch

Subscribe for email updates:

Portrait of Andy Leverenz
Andy Leverenz

March 5, 2023

Last updated November 5, 2023

Let's Build with Hotwire and Rails - Turbo Modals

Welcome to a brand new "Let's Build" tutorial series where I use Rails in the combination of hotwired.dev to create components with little to no JavaScript.

If you're brand new to Turbo, I recommend reading another post titled "Digging into Turbo with Ruby on Rails 7", where I cover what it is and how it works.

In this tutorial, we'll add modals to a basic Rails application with the help of Turbo.

Create a new app

I'll start by creating a new Rails app (Version 7) and passing a couple of options:

  • -c tailwind - Installs and configures Tailwind CSS
  • -j esbuild - Installs and configures ESbuild
rails new turbo_modals -c tailwind -j esbuild

We'll also require a couple of Tailwind CSS plugins which are optional add-ons. Install those with yarn as development dependencies like so:

yarn add @tailwindcss/typography @tailwindcss/forms -D

To use the plugins, we need to require them inside the tailwind.config.js file at the root of the project folder.

// tailwind.config.js
module.exports = {
  content: [
    "./app/views/**/*.html.erb",
    "./app/helpers/**/*.rb",
    "./app/assets/stylesheets/**/*.css",
    "./app/javascript/**/*.js",
  ],
  plugins: [require('@tailwindcss/typography'), require("@tailwindcss/forms")],
}

Create the Post model

For demonstration, we'll assume your blog post authoring and editing experience is performed inside a modal. We first need a Post table in the database to create and edit data.

rails g scaffold Post title:string content:text

invoke  active_record
create    db/migrate/20230305174223_create_posts.rb
create    app/models/post.rb
invoke    test_unit
create      test/models/post_test.rb
create      test/fixtures/posts.yml
invoke  resource_route
  route    resources :posts
invoke  scaffold_controller
create    app/controllers/posts_controller.rb
invoke    erb
create      app/views/posts
create      app/views/posts/index.html.erb
create      app/views/posts/edit.html.erb
create      app/views/posts/show.html.erb
create      app/views/posts/new.html.erb
create      app/views/posts/_form.html.erb
create      app/views/posts/_post.html.erb
invoke    resource_route
invoke    test_unit
create      test/controllers/posts_controller_test.rb
create      test/system/posts_test.rb
invoke    helper
create      app/helpers/posts_helper.rb
invoke      test_unit
invoke    jbuilder
create      app/views/posts/index.json.jbuilder
create      app/views/posts/show.json.jbuilder
create      app/views/posts/_post.json.jbuilder

With the Post resource added, we can set a root route to something more custom. I opted for the posts index page.

# config/routes.rb
Rails.application.routes.draw do
  resources :posts
  # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

  # Defines the root path route ("/")
  root "posts#index"
end

Turbo engage

The next step is leveraging turbo frame tags to hook into the turbo framework. Turbo frames get injected into the DOM by using turbo_frame_tag helpers with unique names.

<!-- app/views/posts/index.html.erb -->
<p style="color: green"><%= notice %></p>

<h1 class="font-extrabold text-3xl">Posts</h1>

<div id="posts">
  <% @posts.each do |post| %>
    <%= render post %>
    <p>
      <%= link_to "Show this post", post %>
    </p>
  <% end %>
</div>

<%= turbo_frame_tag "post_modal" do %>
  <%= link_to "New post", new_post_path, class: "underline" %>
<% end %>

Inside the index.html.erb view, we wrap the link to create a new post in a turbo_frame_tag identified as post_modal. This will look to the path the link is directed towards and insert any content within another turbo_frame_tag directly into the frame in real-time.

The point is to forego redirects that take longer to perform and require a complete page refresh.

To get this to work, the new.html.erb file will need a turbo_frame_tag as a result.

<!-- app/views/posts/new.html.erb -->
<%= turbo_frame_tag "post_modal" do %>
  <div aria-labelledby="modal-title"
    aria-modal="true" class="fixed inset-0 z-50 overflow-y-auto bg-black/80" role="dialog">
    <div class="h-screen w-full relative flex items-center justify-center">
      <div class="shadow-xl max-w-2xl bg-white m-1 p-8 prose prose-indigo origin-bottom mx-auto dark:bg-slate-700 dark:text-slate-200 min-w-[600px] rounded-2xl">

        <h1 id="modal-title" class="dark:text-slate-100">New post</h1>
        <%= render "form", post: @post %>
      </div>
    </div>
  </div>
<% end %>

The new.html.erb template has HTML and CSS that resemble a proper modal component. It's made to fit the entire viewport. Notice all the code is wrapped in a <%= turbo_frame_tag "post_modal" do %> block. This code gets sent "over the wire" in the request cycle and will end in the index view when a user clicks the "New Post" link.

We can repeat the process for the edit.html.erb view file and change up a couple of things.

<!-- app/views/posts/edit.html.erb -->
<%= turbo_frame_tag "post_modal" do %>
  <div aria-labelledby="modal-title"
    aria-modal="true" class="fixed inset-0 z-50 overflow-y-auto bg-black/80" role="dialog">
    <div class="h-screen w-full relative flex items-center justify-center">
      <div class="shadow-xl max-w-2xl bg-white m-1 p-8 prose prose-indigo origin-bottom mx-auto dark:bg-slate-700 dark:text-slate-200 min-w-[600px] rounded-2xl animate-fade-in-up">

        <h1 id="modal-title" class="dark:text-slate-100">Edit post</h1>
        <%= render "form", post: @post %>
      </div>
    </div>
  </div>
<% end %>

Finally, the _form.html.erb partial requires more design love. I'll also add a cancel button so a user can return from the modal experience.

<!-- app/views/posts/_form.html.erb -->
<%= form_with(model: post, data: { turbo: false }) do |form| %>
  <% if post.errors.any? %>
    <div style="color: red">
      <h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2>

      <ul>
        <% post.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="mb-6">
    <%= form.label :title, class: "block mb-1" %>
    <%= form.text_field :title, class: "w-full rounded border border-slate-300 shadow-inner focus:ring-4 focus:ring-slate-50 text-base placeholder:text-slate-500 focus:shadow-none focus:border-slate-400" %>
  </div>

  <div class="mb-6">
    <%= form.label :content, class: "block mb-1"  %>
    <%= form.text_area :content, class: "w-full rounded border border-slate-300 shadow-inner focus:ring-4 focus:ring-slate-50 text-base min-h-[200px] text-base placeholder:text-slate-500 focus:shadow-none focus:border-slate-400" %>
  </div>


  <div class="flex space-x-3 items-center justify-between">
    <%= form.submit class: "rounded bg-teal-600 hover:bg-teal-700 px-4 py-2 text-center text-white font-medium focus:ring-4 focus:ring-teal-50 hover:cursor-pointer" %>

    <%= link_to "Cancel", posts_path, class: "bg-slate-100 rounded px-4 py-2 text-center text-slate-700 font-medium hover:bg-slate-200 focus:ring-4 focus:ring-slate-100 no-underline inline-block", data: { turbo: false } %>
  </div>
<% end %>

Note that the form has a new attribute, data: {Turbo: false}. When a new post is created or updated, I want the app to respond like it usually would if Turbo wasn't in the picture. This small amount of code signifies the ability to bypass it.

The Cancel button has the same attribute as I'd prefer the user redirect to the posts index path as usual.

Adding animation

The modal works great without any JavaScript, but the experience is abrupt. We can account for this and introduce some additional CSS into the mix to provide a fade when the modal appears.

We'll need to extend Tailwind CSS to include additional CSS to do this. This can be performed in several ways, but we'll stick to customizing the tailwind.config.js file once more.

// tailwind.config.js
module.exports = {
  content: [
    "./app/views/**/*.html.erb",
    "./app/helpers/**/*.rb",
    "./app/assets/stylesheets/**/*.css",
    "./app/javascript/**/*.js",
  ],
  theme: {
    extend: {
      keyframes: {
        'fade-in-up': {
          '0%': {
            opacity: '0',
            transform: 'translateY(10px)'
          },
          '100%': {
            opacity: '1',
            transform: 'translateY(0)'
          },
        }
      },
      animation :{
        'fade-in-up': 'fade-in-up 0.3s ease-in-out'
      }
    }
  },
  plugins: [require('@tailwindcss/typography'), require("@tailwindcss/forms")],
}

I extended the tailwind.config.js file to include additional keyframe and animation classes. The one added is called fade-in-up.

Now we can add the class animated-fade-in-up to the modals on the new and edit views.

<!-- app/views/posts/edit.html.erb -->
<%= turbo_frame_tag "post_modal" do %>
  <div aria-labelledby="modal-title"
    aria-modal="true" class="fixed inset-0 z-50 overflow-y-auto bg-black/80" role="dialog">
    <div class="h-screen w-full relative flex items-center justify-center">
      <div class="shadow-xl max-w-2xl bg-white m-1 p-8 prose prose-indigo origin-bottom mx-auto dark:bg-slate-700 dark:text-slate-200 min-w-[600px] rounded-2xl animate-fade-in-up">

        <h1 id="modal-title" class="dark:text-slate-100">Edit post</h1>
        <%= render "form", post: @post %>
      </div>
    </div>
  </div>
<% end %>
<!-- app/views/posts/new.html.erb -->
<%= turbo_frame_tag "post_modal" do %>
  <div aria-labelledby="modal-title"
    aria-modal="true" class="fixed inset-0 z-50 overflow-y-auto bg-black/80" role="dialog">
    <div class="h-screen w-full relative flex items-center justify-center">
      <div class="shadow-xl max-w-2xl bg-white m-1 p-8 prose prose-indigo origin-bottom mx-auto dark:bg-slate-700 dark:text-slate-200 min-w-[600px] rounded-2xl animate-fade-in-up">

        <h1 id="modal-title" class="dark:text-slate-100">New post</h1>
        <%= render "form", post: @post %>
      </div>
    </div>
  </div>
<% end %>

And with that, we have a decent modal experience!

Limitations and possible ways to extend

A few things that aren't present that some modern modals feature include key commands to close the modal as well as clicking outside of the modal to close it. You could add some JavaScript to account for this, but it might be less ideal than just writing the component in JavaScript, HTML, and CSS entirely.

A generic modal component might be a better play than what I've demonstrated. I wouldn't recommend using a modal to make users author content inside, and logging in or signing up for an account might be an exception to that rule.

I hope you found some value in this tutorial. Look for additional Hotwire Turbo and Rails tutorials to come. As the list grows, I'll link to each tutorial.

Tags: hotwire turbo
Link this article
Est. reading time: 8 minutes
Stats: 4,321 views

Categories

Collection

Part of the Hotwire and Rails collection

Products and courses