Dialogs are a staple of almost every modern web application. No matter how hard I try when designing an application, there always seem to be scenarios where a dialog is the best option.

Fortunately, there are some newer techniques that we can employ to make modal dialogs less of a headache, and we can combine Hotwire, some minimal Javascript and some clever CSS to make some good looking dialogs. If we’re being extra ambitious, we can even use View Components to make our markup less daunting.


Styling

To get us started, we’ll create some very bare-bones modal dialogs using nothing but CSS. We can get pretty far just relying on base browser behavior and some clever CSS and pseudo-elements.

Summary/Details Element

The main tool we’ll be relying on is the Details element, and the summary element underneath. This is an element that can give us a native expandable content, which we can use CSS to style into a modal dialog.

The markup to create a detail/summary element will look like the below:


<details class="modal-details">
    <summary>Click me</summary>
    <div class="modal">
        Modal content.
    </div>
</details>

Without any styling, we’ll get a basic expandable element:

Add Some Style

We can add some styling using CSS to help things look not as ugly. The first thing we’ll want to do take our actual modal and position it on the screen. There are many different approaches, but we’ll keep things simple for now and have the modal be in a fixed position towards the top of the page.

.modal-details {
  // We'll just create a single modal for now, but we can optionally create different
  // size or positioned modals as well
  .modal {
    background-color: #ffffff;
    display: flex;
    flex-direction: column;
    width: 448px;
    position: fixed;
    margin: 10vh auto;
    top: 0;
    left: 50%;
    transform: translateX(-50%);
    z-index: 999;
    max-height: 80vh;
    max-width: 90vw;
  }
}

If we were to take a look at the above snippet, things start to look a little bit better. We’re getting there!

Adding A Backdrop

When displaying a modal, it’s good to have some kind of backdrop to help the content standout a bit. Fortunately, we can implement this using pure css. According to the documentation we can use the open attribute to know when the content is expanded or not. We can use this as a selector to add a pseudo-element to the summary element. Let’s see what that would look like:

.modal-details {
  &[open] {
    summary {
      cursor: default;

      &:before {
        content: '';
        display: block;
        width: 100vw;
        height: 100vh;
        background: black;
        position: fixed;
        top: 0;
        left: 0;
        opacity: 0.5;
        z-index: 99;
      }
    }
  }
}

What we’re doing is taking our modal-details element, and adding a :before element to the summary element, but only when the open attribute is present. The open attribute is handled by the browser when the user clicks on the summary element, so that part is handled for us.

Let’s see what that looks like in practice.

Wow - that’s actually not bad at all, considering we haven’t even written any Javascript yet. Since our backdrop is added to the summary element, we get the added benefit of having the modal automatically close when user clicks on the backdrop, as it’s the same as toggling the summary element - nice!

A Sprinkling of Stimulus

In this section, we’ll add a little bit more functionality to our modal dialogs, such as loading turbo frames and adding some nice touches such as closing the modal if the user hits escape.

Let’s start with hitting escape to close the dialog. I’ll assume you’re familiar with Stimulus, but if not head over to their docs to get familiar.

First, we’ll want to create our controller. I use Typescript for all of my Stimulus controllers, so that’s what I’ll use in the snippets below, but it should be pretty easy to translate it to normal Javascript.

import { Controller } from 'stimulus';

export default class extends Controller {

  // Helper method since I don't want to have to cast this every time
  // But this is necessary in Typescript in order for `open` to be a valid property
  get detailElement() {
    return this.element as HTMLDetailsElement;
  }

  disconnect() {
    this.close();
  }

  public toggleDialog() {
    if (this.detailElement.open) {
      this.open();
    } else {
      this.close();
    }
  }

  public open() {
    this.detailElement.open = true;
  }

  public close() {
    this.detailElement.open = false;
  }

  public closeOnEscape(event: KeyboardEvent) {
    if (event.key === 'Escape') {
      event.stopPropagation();
      this.close();
    }
  }
}

In short, we’re adding a few public methods, for toggling the dialog, which can be helpful if we wanted to close or hide the dialog programmatically outside of the summary element, and we’re also adding a quick helper to close on escape.

Let’s take the next step and actually wire-up our markup.


<details class="modal-details"
         data-controller="modal"
         data-action="toggle->modal#toggleDialog keydown->modal#closeOnEscape">
    <summary>Click me!</summary>
    <div class="modal">
        Modal content.

        <button type="button" data-action="modal#close">
            Close me
        </button>
    </div>
</details>

We’ve added an action on the toggle event provided by the details element. It doesn’t really do much yet, as the browser already sets the open state for us, but we’ll take advantage of that in the next section. You might notice we also added a basic close button, which triggers the #close action on the controller. This would allow us to add some styling to get a nice “x” in the corner or an “Ok” button or something.

We also trigger the #closeOnEscape action, which simply inspects the event, looks at the key, and closes if the user hit escape. Aside from accessibility nice-to-haves, this is a pretty complete solution for handling modal dialogs, with very, very little lines of Javascript.

In part two, we’ll go over how to incorporate Turbo Frames to load content dynamically, and then use a View Component to make the markup a little bit more reusable.