Joel Christiansen

Rails Turbo Modals

December 18, 2023
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:

❯ ruby -v
ruby 3.2.2 (2023-03-30 revision e51014f9c0) [x86_64-darwin22]

❯ rails -v
Rails 7.1.2

Generate a new app (options here):

❯ 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:
Rails Welcome Screen


=====
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:
Static Index View


=====
Commit 3: Placeholder Modal Views
-------


Create app/views/shared/modals/_empty.html.erb:

<%= 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:

Rendered empty modal

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:
Placeholder Modal


=====
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.