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.
Categories
Collection
Part of the Hotwire and Rails collection
Products and courses
-
Hello Hotwire
A course on Hotwire + Ruby on Rails.
-
Hello Rails
A course for newcomers to Ruby on Rails.
-
Rails UI
UI templates and components for Rails.