DEV Community

Cover image for Hotwire Modals in Ruby on Rails with Stimulus and Turbo Frames
Ayush Newatia for AppSignal

Posted on • Originally published at blog.appsignal.com

Hotwire Modals in Ruby on Rails with Stimulus and Turbo Frames

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>
Enter fullscreen mode Exit fullscreen mode
const modal = document.querySelector("dialog");
modal.showModal();
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Amend the created route to a more user-friendly path:

# config/routes.rb
Rails.application.routes.draw do
  # ...

  get '/support', to: "support#show"
end
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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) {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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%;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Replace the auto-generated routes with:

Rails.application.routes.draw do
  # ...

  namespace :support do
    resources :tickets, only: [:new, :create]
  end
end
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

<%# ... %>
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)