Accessible Modal With Or Without JavaScript
Tags: html, css, javascript, a11y
Published: 2021-03-01 09:09:27

At my workplace, we recently discussed the various options we have in our toolbox to create modals *without* JavaScript. Basically, if we want a modal that works without JavaScript, we need the `open/close`-state in `html`, limiting our options to:

1. `:target`-selector
2. `<details>`-tag
3. The `checkbox`-hack

In this post I'm gonna focus on `:target`, discuss it's pros and cons, and progressively add JavaScript to handle **focus-trap**.

A modal using `:target` requires the *fragment identifier*: `#`. 

The basic idea is this:

```html
<a href="#modal">Open modal</a>

<div class="c-modal" id="modal">
  Modal content here ...
</div>
```

And in CSS:
```css
.c-modal {
  display: none;
}
.c-modal:target {
  display: block;
}
```

This will hide the `<div class="c-modal">` by default, but whenever there's a *target*:

```
https://your.domain#modal
```

The element matching that target, in this case the element with `id="modal"`, will be shown.

The *Close*-button is simply a link, that removes the target from the current url:

```html
<a href="#">Close modal</a>
```

---

## Pros And Cons

We now have a modal that works with HTML/CSS only, but we can *progressively enhance* it, by adding only a few bits of JavaScript.

But before we do that — let's look at some pros and cons.

**Pros**
- Super-easy to code and maintain
- Works without JavaScript (but I recommend you add some, read on!)

**Cons**
- You can't use the fragment identifier for other stuff, such as routing
- This works best with *root*, so: `yourdomain.com/#modal` instead of `yourdomain.com/document.html#modal`

Do we need to add `role="dialog"` and other aria-enhancements?

Normally, “Yes!”, but in the case of `:target`, I'm tempted to say “No!”.

We're using the fragment identifier `#` to go to text within the same document, so for the screen-reader it's not really a *modal*. We simply jump back and forth between content within the same document. Am I wrong? Please let me know in a comment.

---

## Adding Focus-trap

For the modal to be keyboard-navigable, ie. *accessible*, we need to "trap" the focus, when the modal is open. Whenever you click on a modal, the focus should be set on the first focusable element in the modal. When you press `Tab` (with or without `Shift`), it should cycle between the focusable elements in the modal — until you press `Escape` (or click on the `Cancel/Close`-buttons.

Instead of adding `eventListeners` to all `<a>`-tags that links to modals, we can use the global `window.hashchange`-event:

```js
window.addEventListener('hashchange', (event) => {
 // Handle hashchange
}
```

Within this listener, we can look at `event.newURL`, `event.oldURL` as well as `location.hash`. With these, we can easily detect if the current or previous `url` contains anything that could be interpreted as a modal.

If the current url *is* a modal, we can query it for focusable elements:

```js
const FOCUSABLE = 'button,[href],select,textarea,input:not([type="hidden"]),[tabindex]:not([tabindex="-1"])';
```

I prefer to set this as an `Array`-property on the modal itself: 

```js
modal.__f = [...modal.querySelectorAll(FOCUSABLE)];
```

This way, we can access the list from within the `keydown`-event-handler:

```js
function keyHandler(event) {
/* We just want to listen to Tab- and Escape-
keystrokes. If Tab, prevent default behaviour. */
if (event.key === 'Tab') {
  event.preventDefault();
  /* Get array-length of focusable elements */
  const len =  this.__f.length - 1;
  /* Find current elements index in array of
 focusable elements */
  let index = this.__f.indexOf(event.target);
  /* If shift-key is pressed, decrease index,
 otherwise increase index */
  index = event.shiftKey ? index-1 : index+1;
  /* Check boundaries. If index is smaller 
than 0, set it to len, and vice versa, so 
focus "cycles" in modal */
  if (index < 0) index = len;
  if (index > len) index = 0;
  /* Set focus on element matching new index */
  this.__f[index].focus();
}
/* Set hash to '#' === "Close Modal", when 
Escape is pressed */
if (event.key === 'Escape') location.hash = '#';
}
```

The final `hashchange`-listener, which restores the focus to the **old id** (the link, that triggered the modal) when the fragment identifier changes to `#`, looks like this:

```js
window.addEventListener('hashchange', (event) => {
  const hash = location.hash;
  /* '#' is different from just '#' */
  if (hash.length > 1) {
    const modal = document.getElementById(hash.substr(1));
    if (modal) {
    /* If modal exists, add keydown-listener, 
    set __f-property as an array of focusable elements */
      modal.addEventListener('keydown', keyHandler);
      modal.__f = [...modal.querySelectorAll(FOCUSABLE)];
      /* Set focus on first focusable element */
      modal.__f[0].focus();
    }
  }
  else {
    /* If hash change to just '#', find previous (old) id, 
    remove event, and focus on link, that triggered the modal */
    const [o, oldID] = event.oldURL.split('#');
    if (oldID) {
      document.getElementById(oldID).removeEventListener('keydown', keyHandler);
      document.querySelector(`[href="#${oldID}"]`).focus();
    }
  }
});
```

And that's the gist of it. Minified and gzipped, the code is approx. **400 bytes**.

*Basic demo here:*

{% codepen https://codepen.io/stoumann/pen/dypxBjZ default-tab=result %}

Thanks for reading!