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:
-
:target
-selector -
<details>
-tag - 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>
And in 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:
<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 ofyourdomain.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
}
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"])';
I prefer to set this as an Array
-property on the modal itself:
modal.__f = [...modal.querySelectorAll(FOCUSABLE)];
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 = '#';
}
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();
}
}
});
And that's the gist of it. Minified and gzipped, the code is approx. 400 bytes.
Basic demo here:
Thanks for reading!