At Cycode we use a lot of modals. They allow our users to create/edit/destroy records throughout the app, without leaving the current page. This is pretty common in most modern webapps. Here’s one in action:
This is really handy from a UX point of view and gives your app a Single-Page-App feel. We use Hotwire’s mix of Turbo Frames and stimulus—more on that in a bit—to achieve this, but that isn’t always the case in many apps. The pre-Hotwire approach to displaying modals with Rails would involve either:
A) Modals hidden within CSS and using JS to show/hide the content. Example: Bootstrap modals
B) The “Rails AJAX approach” of using a link_to with remote: true that would direct a controller to respond with a .js.erb template, that would update a DOM with some HTML or render a rails template. Something like this:
# any view <%= link_to "show modal", new_post_path, remote: true %>
# app/controllers/posts_controller.rb def new_post @post = Post.new respond_to do |format| format.js end
// app/views/posts/new.js.erb document.getElementById("new-post").innerHTML = "<%= j render "posts/new", post: @post %>";
By the way, here’s a nice example of an app built using this approach: CRUD AJAX
But that’s not what we do at Bearer:
- The Hidden CSS approach doesn’t fit our needs, because we use Tailwind instead of Bootstrap. We would also have to render a bunch of hidden content that may never be seen. This adds extra burden on the client, and just doesn’t feel right.
- The server-side .js.erb approach never really became popular in production applications, and since the release of Hotwire turbo it became obsolete.
Hotwire allows us to add Single Page App (Turbo Frames), or AJAX (Turbo Streams) functionality without writing any custom Javascript! (It has all been pre-written for us).
In this post we’re going to build a Rails 7 application that allows us to render modals while leveraging all the best bits of CSS, Javascript (Stimulus JS) and Turbo (Frames).
Initial app setup
Create a new app with postgresql and (optionally) tailwind pre-configured by the gem tailwindcss-rails:
rails new hotwire-modal -d postgresql -c tailwind
Next, let’s scaffold out a post, create the db, migrate it, and compile the initial Tailwind css.
Turbo Frames: Server-side rendered content
Add a turbo_frame_tag to your layout so that you can target it from anywhere in your application:
# app/views/layouts/application.html.erb ++<%= turbo_frame_tag "modal" %> <%= yield %>
Now wrap new.html.erb with a turbo_frame_tag:
Now the edit functionality works the same way as the create. Git commit: edit post modal
CSS: Style the modal
But a modal should overlay site content, not move it, right? Otherwise it’s a rather ineloquent slide-in panel.
You could combine Tailwind’s classes, but for now let’s write some custom CSS to take care of it quickly. We’ll come back with Tailwind later.
/* app/assets/application.css */ #modal { position: absolute; z-index: 2; right: 10px; top: 10px; width: 400px; word-break: break-word; border-radius: 6px; background: #bad5ff; }
- Quick CSS tip: position: fixed; stays on the same place when page scrolls, whereas position: absolute; – scrolls down with page.
- z-index: 2; will place on top of the page content.
It’s not perfect, but it no longer interferes with the rest of the page.
Still following along? Here: Git commit: modal css
StimulusJS: Close modal
Usually a modal would have a Close/Cancel button or other way to escape. You might be tempted to “close” a modal by having a link_to to an url without a matching turbo_frame_tag, but this would give you a console error: Response has no matching <turbo-frame id=”modal”> element:
A better way would be to add a button that would close the modal. This is where we can bring in StimulusJS.
First, generate a controller:
bin/rails generate stimulus turbo-modal
Next, add an action that would remove the current element:
TailwindCSS modal styling
Earlier we wrote some custom CSS for the modal, but we already have Tailwind installed, so why not use it? First, remove the custom CSS class that we’ve added in application.css. Next, you can apply some Tailwind classes to the div inside a rendered modal. This should do it “p-5 bg-slate-300 absolute z-10 top-10 right-10 rounded-md w-96 break-words”:
# app/views/posts/new.html.erb <%= turbo_frame_tag "modal" do %> <%= tag.div data: { controller: "turbo-modal" }, style: "padding: 30px;" do %> New post <%= render "form", post: @post %> <%= button_tag "Close", data: { action: "turbo-modal#hideModal" }, type: "button", class: "rounded-lg py-3 px-5 bg-red-600 text-white" %> <% end %> <% end %>
Again, you can do the same in app/views/posts/edit.html.erb. The modal should now look like this:
Here’s the updates: Git commit: Tailwind CSS modal.
ViewComponent: Reusable modal
So now we have A LOT of repeated HTML in app/views/posts/new.html.erb and app/views/posts/edit.html.erb. We would have to add it again in any views that we will want to render inside a modal. ViewComponent to the rescue!
Install the gem and generate a component
bundle add view_component bin/rails generate component TurboModal title
Add the following inside the .rb file, so that you can use turbo_frame_tag ‘modal’ instead of <turbo-frame id=”modal”>:
Turbo Streams: update posts/index without page refresh
Our form works, but ideally you would display the updated/created Post on posts/index without a page refresh. That would be a perfect thing to do with Turbo Streams. Let’s break down the code for a stream:
format.turbo_stream { render turbo_stream: turbo_stream.prepend('posts', partial: 'posts/post', locals: {post: @post}) }
The above code will find the element with DOM ID=posts on the current page and prepend the partial file (_post.html.erb) to it using the data from @post. Here’s what the full snippet looks like:
StimulusJS: handle form submissions
If you comment out the format.turbo_stream in the posts_controller and try to submit the form again, you’ll find a familiar error in the console: Response has no matching <turbo-frame id=”modal”> element. The modal nicely disappears, but we get a console error. This happens, because on a successful response, the controller tries to redirect with a GET request to posts/show.html.erb, butt that page has no turbo_frame_tag ‘modal’. We need a way to correctly handle successful form submissions with Turbo. Luckily, that’s quite easy to do, thanks to turbo events. Specifically, turbo:submit-end can tell us if a form submission is a success or a failure. If it is a success, we can close the modal. Let’s add a submitEnd action to the Stimulus controller:
// app/javascript/controllers/turbo_modal_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { hideModal() { this.element.parentElement.removeAttribute("src") this.element.remove() } // hide modal on successful form submission // action: "turbo:submit-end->turbo-modal#submitEnd" submitEnd(e) { if (e.detail.success) { this.hideModal() } } }
And add a trigger action:
StimulusJS: Close modal with ESC, close when clicked outside modal
So far we can close the modal with the button, and it closes on form submission, but there’s still room to improve. You might want to add a few more common modal behaviors to your app, like:
- click ESC to close
- click outside modal to close
Here is how we can handle closeWithKeyboard and closeBackground in a Stimulus controller:
// app/javascript/controllers/turbo_modal_controller.js import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = ["modal"] // hide modal // action: "turbo-modal#hideModal" hideModal() { this.element.parentElement.removeAttribute("src") // Remove src reference from parent frame element // Without this, turbo won't re-open the modal on subsequent click this.modalTarget.remove() } // hide modal on successful form submission // action: "turbo:submit-end->turbo-modal#submitEnd" submitEnd(e) { if (e.detail.success) { this.hideModal() } } // hide modal when clicking ESC // action: "keyup@window->turbo-modal#closeWithKeyboard" closeWithKeyboard(e) { if (e.code == "Escape") { this.hideModal() } } // hide modal when clicking outside of modal // action: "click@window->turbo-modal#closeBackground" closeBackground(e) { if (e && this.modalTarget.contains(e.target)) { return } this.hideModal() } }
With the actions created, now we need to add them to the modal component:
Bonus: controller actions that respond only to a turbo_frame
At the moment, you can open posts#new or posts#edit in a new tab:
Considering our modal design, that does not look right. You might want these views to be available only in a modal. Only via a turbo_frame:
# app/controllers/posts_controller.rb before_action :ensure_frame_response, only: [:new, :edit] private def ensure_frame_response return unless Rails.env.development? redirect_to root_path unless turbo_frame_request? end
This way, whenever someone tries to open new or edit in a new tab, he will be redirected.
As an additional measure of security, you can replace link_to with button_to, because button_to does not have an “Open in a new Tab” option:
--<%= link_to 'New post', new_post_path, data: { turbo_frame: 'modal' } %> ++<%= button_to 'New post', new_post_path, method: :get, data: { turbo_frame: 'modal' } %>
With that in mind, be sure to avoid replacing a link with a button unless it makes sense, semantically, to do so.
Git commit: controller actions that respond only to a turbo_frame
Final thoughts
We’re done! That may seem like a lot, but in the end we’ve built the beginning of a portal modal component. The mix of Rails 7, Hotwire, ViewComponents, TailwindCSS, and Stimulus provide fantastic synergy for interactions like this. To recap:
- Frames can be used for basic behaviors where parts of the page have their own navigation and actions.
- Streams can be used for situations where frames aren’t enough, such as multiple parts of a page changing based on external data—like an AJAX call.
In our case above, Turbo Frames are superior to Streams for managing the form behavior, as we don’t need to worry about any error rendering behavior in the controller.
Here’s the full Git repo with final result. Interested in more on Ruby and Hotwire? Our engineering team has more content coming soon!