Hotwire Modals in Ruby on Rails with Stimulus and Turbo Frames
posted by Ayush Newatia
21 February, 2024
Modals are widely used on the web, but they are rarely built with accessibility in mind. When a modal is displayed, the background is dimmed visually but it’s still visible to screen readers and keyboard-only users.
But first, let’s see what we need to do to make modals accessible.
How can we make modals accessible?
To make modals accessible, we need to:
- Hide the background from screen readers.
- Implement “focus trapping” so keyboard users cannot focus on elements outside the modal.
- Shift focus to the first focusable element in the modal (if there is one).
- Dismiss the modal with the
Esc
key.
We could accomplish the first two by setting inert="true"
on the background elements, but this attribute can’t be set on an ancestor of the modal. We can do the last two with some more custom JavaScript. As you can imagine this is rather tedious.
The HTML <dialog>
element gives us most of the above list for free, and is supported for 94.25% of global users, meaning it’s ideal in most cases.
Let’s look a bit more closely at the <dialog>
element.
The <dialog>
element
According to MDN:
The HTML
<dialog>
element is used to create both modal and non-modal dialog boxes. Modal dialog boxes interrupt interaction with the rest of the page being inert, while non-modal dialog boxes allow interaction with the rest of the page.
This means that a <dialog>
isn’t necessarily a modal. It can be presented within a document flow as well.
Presenting a <dialog>
To present a dialog as a modal, we need to use JavaScript:
<dialog>
Lorem ipsum ....
</dialog>
const modal = document.querySelector("dialog")
modal.showModal()
When presenting a dialog this way, the background is made inert, focus is trapped in the modal, and it can be dismissed using the Esc
key.
To present a <dialog>
in a non-modal context, and hence make it visible by default, we use the open
attribute:
<dialog open>
Lorem ipsum ....
</dialog>
We won’t get the accessibility features using this method, though, as the dialog hasn’t been presented “modally”.
Dismissing a <dialog>
A modally presented <dialog>
can be dismissed using JavaScript:
const modal = document.querySelector("dialog")
modal.showModal()
modal.close()
It can also be closed using a <form>
with the dialog
method. This is a great way to implement a “close” button.
<dialog>
<header>
<h2>A modal dialog</h2>
<form method="dialog">
<button type="submit">Close</button>
</form>
</header>
Lorem ipsum...
</dialog>
That covers the basics of the <dialog>
element, so let’s look at using it with Hotwire.
Modal presentation using Stimulus
Stimulus is a JavaScript library under the Hotwire umbrella. It allows us to attach pieces of JavaScript logic to HTML elements encapsulated in controllers. This post assumes a basic familiarity with its API.
Let’s start with a Rails controller and view. As an example use case, we’ll use a support page which has a button to display some contact details in a modal. Generate a controller and action using:
$ bin/rails generate controller support show
Amend the created route to a more user-friendly path:
# config/routes.rb
Rails.application.routes.draw do
# ...
get '/support', to: "support#show"
end
Sketch out a button and dialog in the newly generated view file:
<%# app/views/support/show.html.erb %>
<button>
Show contact details
</button>
<dialog aria-labelledby="modal_title">
<header>
<h2 id="modal_title">
Contact details
</h2>
<form method="dialog">
<button aria-label="close">X</button>
</form>
</header>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</p>
</dialog>
Run the Rails server and navigate to the /support
path. You should see a button there, but it doesn’t do anything at the moment. Let’s create a Stimulus controller and wire it up.
The Stimulus Controller in Rails
Use the generator to create a Stimulus controller.
$ bin/rails generate stimulus modal
When the button is clicked, we need to get a reference to the <dialog>
and call showModal()
on it. To keep the controller generic, we’ll pass in the element’s id
as a param.
// app/javascript/controllers/modal_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="modal"
export default class extends Controller {
show(event) {
const dialog = document.getElementById(event.params.dialog)
dialog.showModal()
}
}
We can now decorate the button with the required data-
attributes:
<%# app/views/support/show.html.erb %>
<button
data-controller="modal"
data-action="modal#show"
data-modal-dialog-param="contact_details_modal">
Show contact details
</button>
<dialog id="contact_details_modal" aria-labelledby="modal_title">
<%# ... %>
</dialog>
Refresh the page and the button should now work!
There’s an opportunity to remove some boilerplate code here. The modal
controller should always be attached to a button which shows the modal. We can remove the data-action
from the markup and set it in the controller:
// app/javascript/controllers/modal_controller.js
// ...
export default class extends Controller {
connect() {
this.element.dataset.action = "modal#show"
}
show(event) {
// ...
}
}
Styling the modal
The dialog looks fairly basic, so let’s add some styles:
/* app/assets/stylesheets/application.scss */
dialog {
width: 80vw;
margin: auto;
&::backdrop {
background: red;
opacity: 0.2;
}
header {
display: flex;
align-items: center;
h2 {
flex: 0 1 100%;
}
}
}
The ::backdrop
pseudo element is a really great way to style the modal’s background!
Try using the Tab
key to navigate around the page and you’ll see that the focus is trapped within the modal. It’s worth reviewing the page with a screen reader as well.
This is the simplest way to modally present a <dialog>
. Next, we’ll look at a server-driven way to present modals: Turbo Frame.
Turbo Frame powered modals
Turbo Frames are a subset of the Turbo library which is also under the Hotwire umbrella. It allows us to scope navigation to specific parts of a page, updating them in isolation from the rest of the page.
We can use Turbo Frames to present a modal rendered from the server.
Setting up
Let’s add another button to display a modal contact form. We’ll need a controller and action to render the form:
$ bin/rails generate controller support/tickets new create
Replace the auto generated routes with:
Rails.application.routes.draw do
# ...
namespace :support do
resources :tickets, only: [:new, :create]
end
end
We’ll also need a global Turbo Frame to render modals, so let’s put it in the main application layout:
<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html>
<%# ... %>
<body>
<%= yield %>
<%= turbo_frame_tag :remote_modal %>
</body>
</html>
Add a link to show the contact form:
<%# app/views/support/show.html.erb %>
<button
data-controller="modal"
data-modal-dialog-param="contact_details_modal">
Show contact details
</button>
<%= link_to new_support_ticket_path, data: { turbo_frame: :remote_modal } do %>
Show contact form
<% end %>
<%# ... %>
And you’re done!
Rendering and presenting the form
Clicking the Show contact form link will expect a <turbo-frame>
with id="remote_modal"
in the response and update the global Turbo Frame with its contents. Fill in the view with the form:
<%# app/views/support/tickets/new.html.erb %>
<%= turbo_frame_tag :remote_modal do %>
<dialog id="contact_form_modal" aria-labelledby="modal_title">
<header>
<h2 id="modal_title">
Contact us
</h2>
<form method="dialog">
<button aria-label="close">X</button>
</form>
</header>
<%= form_with(url: support_tickets_path) do |form| %>
<%= form.label :message, "Your message" %>
<%= form.text_area :message, autofocus: true %>
<%= form.button "Close", value: nil, formmethod: :dialog %>
<%= form.button "Send" %>
<% end %>
</dialog>
<% end %>
We’ve now got a text area in the modal which is a focusable element. For accessibility, it should be focused by default when the modal is presented. The autofocus
attribute is used to accomplish this.
Refresh the page and try clicking Show contact form. Nothing will happen visually, but on inspecting the HTML, you’ll notice the <dialog>
has been rendered in the remote_modal
Turbo Frame. We haven’t presented it yet, which is why it’s invisible.
We could render it with the open
attribute, but that would defeat the purpose as it’d be presented in a non-modal context without the modal accessibility features.
Let’s create another Stimulus controller to present the form:
$ bin/rails generate stimulus remote_modal
// app/javascript/controllers/remote_modal_controller.js
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="remote-modal"
export default class extends Controller {
connect() {
this.element.showModal()
}
}
And hook it up to the dialog:
<%# app/views/support/tickets/new.html.erb %>
<%= turbo_frame_tag :remote_modal do %>
<dialog
id="contact_form_modal"
aria-labelledby="modal_title"
data-controller="remote-modal">
<%# ... %>
</dialog>
<% end %>
Refresh the page and try viewing the contact form again. This time it should work. We’ve just rendered a modal from the server!
While this is quite convenient, it’s not ideal to create a new Stimulus controller just to present a modal. There’s another method we can use as well: Turbo Streaming Modals.
Turbo Streaming modals
Turbo Streams is a subset of Turbo. It allows us to make fine-grained, targeted updates to a page. By default, it contains seven CRUD actions, but we’re free to add more actions within our applications.
Now, we’ll create a show_remote_modal
action which renders and presents the <dialog>
from our previous post.
Creating a custom Action
Create a folder to place all custom Stream Actions in:
$ mkdir app/javascript/stream_actions
$ touch app/javascript/stream_actions/index.js
And a file for the Action:
$ touch app/javascript/stream_actions/show_remote_modal.js
Import the Stream Actions into the application:
// app/javascript/stream_actions/index.js
import "./show_remote_modal"
// app/javascript/application.js
// ...
import "stream_actions"
If you’re using import maps, you’ll need to update the config and restart the server:
# config/importmap.rb
# ...
pin_all_from "app/javascript/stream_actions", under: "stream_actions"
Change the global remote modal container to an HTML element instead of a Turbo Frame:
<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html>
<html>
<%# ... %>
<body>
<%= yield %>
<remote-modal-container></remote-modal-container>
</body>
</html>
The custom Stream Action can be implemented as:
// app/javascript/stream_actions/show_remote_modal.js
Turbo.StreamActions.show_remote_modal = function() {
const container = document.querySelector("remote-modal-container")
container.replaceChildren(this.templateContent)
container.querySelector("dialog").showModal()
}
In the above snippet, this
refers to StreamElement
, which is the custom element underpinning <turbo-stream>
. The templateContent
getter is defined by this element.
Using the Action with a Rails Helper
Since this is a custom Action, we’ll need to manually create a Rails helper to use it.
$ bin/rails generate helper TurboStreamActions
# app/helpers/turbo_stream_actions.rb
module TurboStreamActionsHelper
def show_remote_modal(&block)
turbo_stream_action_tag(
:show_remote_modal,
template: @view_context.capture(&block)
)
end
end
Turbo::Streams::TagBuilder.prepend(TurboStreamActionsHelper)
We can now use this helper in our views.
<%# app/views/support/tickets/new.html.erb %>
<%= turbo_stream.show_remote_modal do %>
<dialog id="contact_form_modal" aria-labelledby="modal_title">
<header>
<h2 id="modal_title">
Contact us
</h2>
<form method="dialog">
<button aria-label="close">X</button>
</form>
</header>
<%= form_with(url: support_tickets_path) do |form| %>
<%= form.label :message, "Your message" %>
<%= form.text_area :message, autofocus: true %>
<%= form.button "Close", value: nil, formmethod: :dialog %>
<%= form.button "Send" %>
<% end %>
</dialog>
<% end %>
Remember to remove the data-controller
attribute: we don’t need it any more. In fact, we can delete the controller itself.
$ rm app/javascript/controllers/remote_modal_controller.js
We’ll also need to change the template’s name so it renders as a Turbo Stream.
$ mv \
app/views/support/tickets/new.html.erb \
app/views/support/tickets/new.turbo_stream.erb
Turbo Streams are disabled by default for GET
requests, so we’ll need to manually enable them for the link:
<%# app/views/support/show.html.erb %>
<%# ... %>
<%= link_to new_support_ticket_path, data: { turbo_stream: true } do %>
Show contact form
<% end %>
<%# ... %>
Refresh the page and click Show contact form. It should still work as before, but now it’s rendered using a custom Stream Action!
Wrapping up
In this post, we explored three different methods to present modals using Hotwire: Stimulus, Turbo Frames, and Turbo Streams. More importantly, the modals have been presented with accessibility as the main consideration.
The web should be usable by everyone and it’s important for us, as web developers, to put in the effort to make websites accessible.
Basecamp’s accessibility guide is publicly available and a fantastic resource to learn the ropes.
I also recommend checking out the docs for Stimulus and Turbo to familiarise yourself with all their features and the APIs used in this post.
This post was initially published on AppSignal's blog.