Accessible Modal With Or Without JavaScript

Mads Stoumann - Mar 1 '21 - - Dev Community

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:

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

<div class="c-modal" id="modal">
  Modal content here ...
</div>
Enter fullscreen mode Exit fullscreen mode

And in CSS:

.c-modal {
  display: none;
}
.c-modal:target {
  display: block;
}
Enter fullscreen mode Exit fullscreen mode

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

https://your.domain#modal
Enter fullscreen mode Exit fullscreen mode

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:

<a href="#">Close modal</a>
Enter fullscreen mode Exit fullscreen mode

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:

window.addEventListener('hashchange', (event) => {
 // Handle hashchange
}
Enter fullscreen mode Exit fullscreen mode

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:

const FOCUSABLE = 'button,[href],select,textarea,input:not([type="hidden"]),[tabindex]:not([tabindex="-1"])';
Enter fullscreen mode Exit fullscreen mode

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

modal.__f = [...modal.querySelectorAll(FOCUSABLE)];
Enter fullscreen mode Exit fullscreen mode

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

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 = '#';
}
Enter fullscreen mode Exit fullscreen mode

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:

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

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

Basic demo here:

Thanks for reading!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player