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!
Top comments (5)
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) ortabindex="-1"
.Also when you enable JavaScript interception of the link you change it into a
<button>
effectively, so you should addaria-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 againrole="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)
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.
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).
Again, thank you for your comprehensive answer!
To recap:
:target
-based modal works just fine for non-screen-readers using only CSS and HTML.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 tofalse
on thehashchange
-event?And set the
aria-label
to "Back to previous loccation"?Great answer and TIL about the
inert
property.