DEV Community

loading...
Cover image for Accessible Modal With Or Without JavaScript

Accessible Modal With Or Without JavaScript

madsstoumann profile image Mads Stoumann ・3 min read

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!

Discussion (5)

pic
Editor guide
Collapse
inhuofficial profile image
InHuOfficial • Edited

Sadly this is not accessible for screen reader users and there are a few things you need to do.

Firstly screen reader users navigate with shortcuts for headings 1-6, sections, links on the page, buttons etc. They can jump out of your modal with any of these shortcuts.

In order to stop this (and it is quite difficult) you need to first add aria-hidden="true" to anything outside your modal. That bit is easy enough, especially if you structure your document correctly.

Where it gets difficult is that anything that is focusable outside your modal needs either disabled adding to it (if appropriate) or tabindex="-1".

Also when you enable JavaScript interception of the link you change it into a <button> effectively, so you should add aria-role="button" to it to indicate that this will not result in navigation.

At the same time you should add aria-haspopup="dialog". aria-labelledby="dialogTitle" and still use `role="dialog" on the dialog itself.

Also use semantic HTML (the <dialog> element is a great place to start if creating a dialog as in some browsers you get focus trapping built in) and yet again role="button" on your close links (or better yet, use JavaScript to change them out for actual buttons as I think about it).

There are probably other things I haven't thought about from a quick glance through but that should give you a good start on things to fix.

Oh and this may soon get really easy if inert ever gets implemented properly in browsers (a lot of browsers have partially implemented it behind a flag but as of yet 0 default browser support)

Collapse
madsstoumann profile image
Mads Stoumann Author • Edited

Thanks for your comprehensive review!

My whole point is not looking at it as a modal for screen-readers, but as content you navigate to and from, using the fragment identifier # (and bits of JS to focus on the previous id, when you change the fragment identifier to just #).

I assume inline links (yourdomain.com/#about) works just as well on screen-readers?

For people without screen-readers it will function as a modal, with focus-trapping etc., but for screen-readers as content you navigate to and from.

PS! I just tried "Narrator" in Windows, and it correctly jumped to the modal and back when I “closed” it.

Collapse
inhuofficial profile image
InHuOfficial

The link part would work fine, the problem comes with understanding content. A hyperlink saying "close" is not very useful to a screen reader user without knowing they are in a modal for example (could possibly be fixed with an aria-label explaining but still not sure if that is ideal).

Also in a sandbox it is fine but in the real world a modal would be used to alert information etc. So at places where you are likely to use it it would be expected to have a button anyway (such as in a form)? (Maybe there is a use case but I can't think of it at the moment)

The other issue is when JavaScript is turned off if you open the second "modal" and then close it you are returned to the top of the page, could possibly be fixed by giving the link that opens it an ID and making the close buttons point at that instead though?

One thing that would improve it is moving the id to the heading of the "modal" but that would make the selector hard work for :target.

As a concept I do think it is good, but the problem is expected behaviour.

The use case I can think of is for cookies consent if you had the "modal" open on page load.

I will think about it more as I think you have a concept that probably has some good use cases but until I think of the use cases I cannot recommend best actions to fix things (if you have some use cases in mind that would be great).

Thread Thread
madsstoumann profile image
Mads Stoumann Author

Again, thank you for your comprehensive answer!

To recap:

  1. The :target-based modal works just fine for non-screen-readers using only CSS and HTML.
  2. With JS enabled, it works fine for people like me, who cannot use a mouse.
  3. But for screen-readers, with inline links, the behaviour can be unexpected.

I need to know how screen-readers normally announce inline links, using fragment identifiers - I'll look into that.

Because, could it potentially be enough to set aria-hidden="true" on the modal by default, and change this to false on the hashchange-event?
And set the aria-label to "Back to previous loccation"?

Collapse
maxart2501 profile image
Massimo Artizzu

Great answer and TIL about the inert property.