Implementing Modals using Turbo Frames, modern CSS and minimal JavaScript
I recently implemented modals on a side project and thought I'd share how I did it. They were surprisingly straightforward to implement when leveraging Turbo Frames, Tailwind CSS and very minimal JavaScript.
If you prefer rawdogging it with pure CSS then feel free to drop Tailwind. My preference is Tailwind due to how much more productive I find working from the same file. Plus, I discovered a neat way to use a modern CSS selector in Tailwind by creating my own custom variant. More on this later.
For this blog post I've made available the source repo here and a live demo here. I'm also assuming at least basic understanding of Ruby on Rails.
To demonstrate the modals we'll create a simple blog. We'll use a Post
model with title
and content
attributes and we'll create a PostsController
with all the typical CRUD actions you'd expect (check the source repo if you're not sure about this part).
Editing a post inside a modal
For our example we will add an 'edit' link to our post's show page. When the 'edit' link is clicked a modal will appear and the post's edit form be injected inside the modal and allow the post to be edited from the show page.
The first step is to create a modal partial which we can reuse throughout our application. In our modal we'll use a dialog element which is perfect for creating modals. We'll also include a Turbo Frame within our modal and give it an ID of 'modal'.
For now we'll add tailwind's hidden
class to make sure this modal is hidden by default. There's also a bunch of other classes I've added for positioning and blurring the background content. Adjust as you see fit.
<%# app/views/shared/_modal.html.erb %>
<dialog class="fixed inset-0 bg-gray-50/90 backdrop-blur-xs w-full h-full p-4 hidden">
<div class="relative w-full max-w-2xl mx-auto mt-20 bg-white rounded-lg shadow-md border border-gray-200">
<div class="p-6">
<%= turbo_frame_tag "modal", target: "_top" %>
</div>
</div>
</dialog>
Finally, to make this available for use throughout our application we need to render this in our application.html.erb
.
After creating the modal we need amend or create our existing edit.html.erb
template. See the code below - it's a standard edit page with a form for capturing user data. You may have noticed a turbo frame tag with the same ID of 'modal'. This is important to note because when it comes to injecting content into our modal, the turbo frames need to have matching IDs. More on this later...
<%# app/views/posts/edit.html.erb %>
<div class="max-w-4xl mx-auto px-4 py-8">
<div class="bg-white rounded-lg shadow-md p-6">
<%= turbo_frame_tag "modal" do %>
<h1 class="text-3xl font-bold text-gray-800 mb-6">Edit Post</h1>
<%= form_with model: @post, class: "space-y-6" do |f| %>
<div>
<%= f.label :title, class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :title, class: "w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" %>
</div>
<div>
<%= f.label :content, class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_area :content, rows: 10, class: "w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" %>
</div>
<div>
<%= f.submit "Update Post",
class: "text-gray-600 hover:text-gray-900 hover:cursor-pointer border border-gray-400 hover:border-gray-600 px-4 py-2 rounded-md transition-colors" %>
</div>
<% end %>
<% end %>
</div>
</div>
Adding a link
Next we'll add a link and when clicked it will open our modal and inject the edit form within it.
Typically when a user clicks a link to our edit page it does a HTTP request to the server, the server processes the request and then it returns a complete HTML page for the browser to render.
With the modals however, we want to 'hijack' this typical HTTP request. We do so by adding a data-turbo-frame: 'modal'
attribute to the link, as shown below.
<%= link_to "edit", edit_post_path(@post), data: { turbo_frame: "modal" } %>
Adding the attribute allows Turbo to intercept the link click and instead tells turbo to find a matching turbo frame in the DOM with an ID of "modal".
Turbo will then perform an AJAX request to the edit_post_path
and it'll return our edit.html.erb
template in the response, however turbo is clever enough to only use the content inside our Turbo Frame (remember we gave it a matching 'modal' ID).
It then takes the edit page frame content and inserts it into the modal's turbo frame. Not bad!
There's still one problem, our modals are hidden! So even though the content has been injected it's not visible to the user.
Showing our Modal
Most people would reach for JavaScript at this point which is totally fine. However, rather than adding or removing classes using JavaScript we can essentially have a CSS rule which says if this frame has content, make it visible, else it should be hidden.
To accomplish this we can use a little bit of CSS in the form of the relatively new :has()
selector. The solution I am going to show uses a Tailwind custom variant. If you prefer pure CSS don't worry, you will get the gist of it.
Tailwind custom variants are something which I have only just discovered. They essentially allow you to extend Tailwind with your own utility classes which come in handy for situations like this.
In our tailwind.config.js
, we want add the below variant. If you're familiar with Tailwind, you'll be aware it let's you do things like this hover:cursor-pointer
, i.e. when an element is hovered over it will change the cursor to be a pointer. It's what makes Tailwind so powerful and productive.
We can do something similar with our own variant. We'll create a variant called has-turbo-frame-content
. When used on an element (our modal) it can check if the element has a turbo frame within it AND the frame is not empty. If it's not empty then it we want to make the modal visible by changing it from hidden (display: none) to visible (display: block).
module.exports = {
content: [
"./app/views/**/*.html.erb",
"./app/helpers/**/*.rb",
"./app/assets/stylesheets/**/*.css",
"./app/javascript/**/*.js",
],
plugins: [
({ addVariant }) => {
addVariant("has-turbo-frame-content", "&:has(turbo-frame:not(:empty))");
},
],
};
In our view file, we can use this variant by adding has-turbo-frame-content:block
to our modal classes.
<%# app/views/shared/_modal.html.erb %>
<dialog class="fixed inset-0 bg-gray-50/90 backdrop-blur-xs w-full h-full p-4 hidden has-turbo-frame-content:block">
<div class="relative w-full max-w-2xl mx-auto mt-20 bg-white rounded-lg shadow-md border border-gray-200">
<div class="p-6">
<%= turbo_frame_tag "modal", target: "_top" %>
</div>
</div>
</dialog>
Excellent. If you made it this far you can now successfully click the edit link and the modal will appear with the edit form successfully injected inside of it.
Sprinkles of JavaScript
To round this off, let's add a close button and the ability to click outside the modal to close it. For this we do need a sprinkle of JavaScript.
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["frame"];
closeBackdrop(event) {
if (event.target === this.element) {
this.frameTarget.innerHTML = "";
}
}
closeButton() {
this.frameTarget.innerHTML = "";
}
}
You'll notice in the stimulus controller all we need to do is clear the inner html and our use of the :has()
selector will detect there is no content, thus it will revert to the default state of hidden!
Finally, here is the final modal partial with the close button and stimulus data attributes added.
<%# app/views/shared/_modal.html.erb %>
<dialog data-controller="modal" data-action="click->modal#closeBackdrop" class="fixed inset-0 bg-gray-50/90 backdrop-blur-xs w-full h-full p-4 hidden has-turbo-frame-content:block">
<div class="relative w-full max-w-2xl mx-auto mt-20 bg-white rounded-lg shadow-md border border-gray-200">
<div class="absolute top-4 right-4 text-gray-500 hover:text-gray-800 transition-colors rounded-lg p-1 cursor-pointer" data-action="click->modal#closeButton">
<span class="sr-only">Close</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<div class="p-6">
<%= turbo_frame_tag "modal", target: "_top", data: { modal_target: "frame" } %>
</div>
</div>
</dialog>
Wrapping up
If you made it this far I hope you found this blog post useful. I really do like this modal implementation due to the amount of flexibility it provides and thanks to the Hotwire libraries it really doesn't take an awful lot of effort either.
Don't forget to check out the source repo and live demo if you want to play around with this yourself.