Below is a quick approach for building modals without JS using Rails + Turbo. This is a simple example, but it's also extensible. This walkthrough includes setting up a new app from scratch following the commits in this repo.
=====
Commit 1: Create New App
-------
Confirm Ruby and Rails versions:
=====
Commit 1: Create New App
-------
Confirm Ruby and Rails versions:
❯ ruby -v ruby 3.2.2 (2023-03-30 revision e51014f9c0) [x86_64-darwin22]
❯ rails -v Rails 7.1.2
❯ rails new rails-turbo-modals -d postgresql -j esbuild -c tailwind
Change into the new app directory and start a server:
❯ cd rails-turbo-modals ❯ bin/dev
Visit
localhost:3000
in a browser to confirm Rails is running:=====
Commit 2: Create a Static Page
-------
Create a new controller at
app/controllers/static_controller.rb
:class StaticController < ApplicationController end
Create a static index view at
app/views/static/index.html.erb
:<div> <%= link_to "Click here", "#", class: "text-blue-500 hover:text-blue-700 hover:underline" %> to open a modal. </div>
Add minor Tailwind styling to the
<body>
element in app/views/layouts/application.html.erb
:<!DOCTYPE html> <html> <head> <title>RailsTurboModals</title> <meta name="viewport" content="width=device-width,initial-scale=1"> <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %> </head> <body class="container mx-auto mt-8 max-w-xl"> <%= yield %> </body> </html>
Point the root path to the
static#index
controller action (Rails will infer the index action and route it to the appropriate view):Rails.application.routes.draw do # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check # Defines the root path route ("/") root "static#index" end
Now the static index view should be visible at
localhost:3000
:<%= turbo_frame_tag "modal" %>
Render the empty modal in
app/views/layouts/application.html.erb
:<!DOCTYPE html> <html> <head> <title>RailsTurboModals</title> <meta name="viewport" content="width=device-width,initial-scale=1"> <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %> </head> <body class="container mx-auto mt-8 max-w-xl"> <%= yield %> <%= render "shared/modals/empty" %> </body> </html>
There is now a
<turbo-frame>
element in the DOM:Create a placeholder modal with Tailwind styling at
app/views/shared/modals/_modal.html.erb
:<%= turbo_frame_tag "modal" do %> <div class="flex fixed top-0 right-0 bottom-0 left-0 z-[48] bg-gray-900 bg-opacity-50"> <div class="fixed inset-0 flex justify-center overflow-y-auto z-[50]" > <div class="relative w-full max-w-[700px] max-h-screen mt-24 md:mt-12"> <div class="m-1 bg-white rounded shadow"> <div class="flex justify-between items-center px-4 py-2 h-12 bg-gray-100 rounded-t-lg"> <div class="absolute top-0 right-0 pt-4 pr-4 sm:block"> <%= link_to "#", data: { turbo_stream: true }, class: "text-gray-400 rounded-md hover:text-gray-500" do %> <span class="sr-only">Close</span> <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> </svg> <% end %> </div> </div> <div class="p-4 mb-4"> <%= yield %> </div> </div> </div> </div> </div> <% end %>
To confirm this is working, comment out the empty modal and render the partial as a template with some content in
app/views/layouts/application.html.erb
:<!DOCTYPE html> <html> <head> <title>RailsTurboModals</title> <meta name="viewport" content="width=device-width,initial-scale=1"> <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %> </head> <body class="container mx-auto mt-8 max-w-xl"> <%= yield %> <%# render "shared/modals/empty" %> <%= render layout: "shared/modals/modal" do %> <div>Placeholder modal</div> <% end %> </body> </html>
The placeholder modal should now appear on page reload:
=====
Commit 4: Implement Modal Mechanics
-------
Remove the placeholder modal used in the previous step from
app/views/layout/application.html.erb
:<!DOCTYPE html> <html> <head> <title>RailsTurboModals</title> <meta name="viewport" content="width=device-width,initial-scale=1"> <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %> </head> <body class="container mx-auto mt-8 max-w-xl"> <%= yield %> <%= render "shared/modals/empty" %> </body> </html>
Create
app/views/static/heyyy.turbo_stream.erb
:<%= turbo_stream.update "modal" do %> <%= render layout: "shared/modals/modal" do %> <div>Heyyy</div> <% end %> <% end %>
Create a route for the
/heyyy
path in config/routes.rb
:Rails.application.routes.draw do # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html get "heyyy", to: "static#heyyy", as: :heyyy # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check # Defines the root path route ("/") root "static#index" end
Link to the new path from
app/views/static/index.html.erb
, setting a data attribute that sends the request as a turbo stream request:<div> <%= link_to "Click here", heyyy_path, data: { turbo_stream: true }, class: "text-blue-500 hover:text-blue-700 hover:underline" %> to open a modal. </div>
Now, when you click on the link, it will open in the modal. Notice in the server logs how the request is sent as a
TURBO_STREAM
request:08:39:51 web.1 | Started GET "/heyyy" for ::1 at 2023-12-19 08:39:51 -0800 08:39:51 web.1 | Processing by StaticController#heyyy as TURBO_STREAM 08:39:51 web.1 | Rendering static/heyyy.turbo_stream.erb 08:39:51 web.1 | Rendered shared/modals/_modal.html.erb (Duration: 1.0ms | Allocations: 330) 08:39:51 web.1 | Rendered static/heyyy.turbo_stream.erb (Duration: 3.9ms | Allocations: 848) 08:39:51 web.1 | Completed 200 OK in 6ms (Views: 5.5ms | ActiveRecord: 0.0ms | Allocations: 1339) 08:39:51 web.1 | 08:39:51 web.1 |
Because the request is sent as a Turbo stream, the
static#heyyy
action renders the app/views/static/heyyy.turbo_stream.erb
view. That file uses Turbo to update the modal turbo-frame and renders the content inside the modal partial.To close the modal, create a controller at
app/controllers/shared/modals_controller.rb
(I'm using a namespaced controller here to keep things tidy and easily reusable):class Shared::ModalsController < ApplicationController end
Create a view at
app/views/shared/modals/close.turbo_stream.erb
:<%= turbo_stream.update "modal" do %> <%= render "shared/modals/empty" %> <% end %>
Create a route to a
modal#close
action at config/routes.rb
:Rails.application.routes.draw do # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html get "heyyy", to: "static#heyyy", as: :heyyy get "modals/close", to: "shared/modals#close", as: :close_modal # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check # Defines the root path route ("/") root "static#index" end
Finally, update the link in
app/views/shared/modals/modal.html.erb
to point to the new route using a turbo stream request:<%= turbo_frame_tag "modal" do %> <div class="flex fixed top-0 right-0 bottom-0 left-0 z-[48] bg-gray-900 bg-opacity-50"> <div class="fixed inset-0 flex justify-center overflow-y-auto z-[50]" > <div class="relative w-full max-w-[700px] max-h-screen mt-24 md:mt-12"> <div class="m-1 bg-white rounded shadow"> <div class="flex justify-between items-center px-4 py-2 h-12 bg-gray-100 rounded-t-lg"> <div class="absolute top-0 right-0 pt-4 pr-4 sm:block"> <%= link_to close_modal_path, data: { turbo_stream: true }, class: "text-gray-400 rounded-md hover:text-gray-500" do %> <span class="sr-only">Close</span> <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> </svg> <% end %> </div> </div> <div class="p-4 mb-4"> <%= yield %> </div> </div> </div> </div> </div> <% end %>
We now have a simple functioning modal. I hope you have fun extending this for additional use cases.