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.
In this post, the first of a two-part series, we'll look at presenting accessible modals in Rails using two different approaches powered by Hotwire's Turbo and Stimulus libraries.
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 the 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 in Ruby on Rails
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 that 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 for Ruby
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 content. 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. We'll take a look at those in part two of this series.
Up Next: Turbo Streaming Modals
In this post, we explored two different methods to present modals using Hotwire: Stimulus and Turbo Frames.
In part two, we'll look at another way to present modals: using Turbo Streams.
Until then, happy coding!
P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!