I recently read this article by Keith Grant which introduced the newly arrived <dialog>
. Excited by this new UI element, I immediately sat down to experiment with it to see how it can be used effectively as a modal — the most common use of it. While experimenting, I discovered a neat CSS trick on how to trap focus within the element, a common accessibility requirement for modals, and a notoriously difficult one.
Disclaimer: The <dialog>
demos in this article are tested on Chrome and Firefox browsers only. Safari has some weird issue where not all elements are focused while doing normal keyboard navigation with Tab key!
What is focus trapping?
First, a quote from the W3C documentation regarding what should happen following a keypress inside a dialog:
Tab:
- Moves focus to the next tab-able element inside the dialog.
- If focus is on the last tab-able element inside the dialog, moves focus to the first tab-able element inside the dialog.
Shift + Tab
- Moves focus to the previous tab-able element inside the dialog.
- If focus is on the first tab-able element inside the dialog, moves focus to the last tab-able element inside the dialog.
To summarize, when inside a dialog, pressing Tab
or Shift+Tab
should cycle the focus within the dialog only—amongst the focusable elements inside the dialog.
This makes sense because when a dialog is open, a user is interacting only inside it and allowing the focus to escape outside the dialog would essentially be mixing contexts and possibly create a state where the user doesn’t know which element is in focus.
So, going back to the idea of a modal, our expectation would be that tabbing inside of the modal would only focus on elements inside of the modal. Anything outside of the context of the modal would be out of scope because the tab is only concerned with what is inside of it. This is what we mean by focus trapping.
An implementation with JavaScript
If we were to implement focus trapping inside a <dialog>
, the most common approach would be to do the following when the dialog opens:
1. Grab all the focusable/tappable elements inside the dialog.
2. Listen for Tab
and Shift+Tab
keypresses and manually focus the next or previous element, respectively.
3. If the keypress happens on the first focusable element, then focus the last focusable element in the chain and vice versa.
This way, we create a loop on focus as the user presses Tab
or Shift+Tab
. See this W3C code snippet as an example of how this might be approached with JavaScript. You’ll see it’s quite a bit of JavaScript.
:focus-within
Enter Back to my experiment with the new <dialog>
element. When thinking about focus trapping, a CSS pseudo-class (also very recent in browsers) immediately came to my mind : :focus-within
.
If you have not heard about that before, it represents an element that has received focus or contains an element that has received focus. So, for example, you have a <div>
and inside of it is an input
element. If you want to style that <div>
when the contained input
has focus, you can do it like so:
div:focus-within {
border: 2px solid red;
}
The CSS trick to focus trapping
Let’s exploit :focus-within
and CSS transitions to implement a basic focus trap inside of a <dialog>
element.
To summarise, here is how the trick works. When the focus is not within the dialog (and the dialog is open), we:
- trigger a CSS transition
- detect that transition completion in JavaScript
- focus the first element in the dialog
But first, let’s get set up. Here is the basic dialog and opening functionality:
<button id="button">Open dialog</button>
<dialog id="modal">
<form action="">
<label>
<input type="text" /> Username
</label>
<label>
<input type="password" /> Password
</label>
<input type="submit" value="Submit" />
</form>
</dialog>
button.onclick = () => {
modal.showModal();
}
There we go. Clicking on the button should open our dialog. Just this much code required to make a basic working modal using the new <dialog>
Note: As in the above example demo of dialog, you’ll notice some extra polyfill code to make <dialog>
work in browsers where it isn’t supported.
If you opened the dialog in the example above and started tabbing several times, you may have already noticed the problem: the focus starts with elements in the dialog, but then leaves once the last element in the dialog has been passed.
This is the core of our trick. We somehow need to send the lost focus detected with :focus-within
over to JavaScript so that we can send the focus back to the dialog. This is where CSS transitions come into play. A CSS transition is something that happens through CSS, but emits events in JavaScript too. In our case, we can trigger a transition on any property with a negligible (because it doesn’t matter in our case) visual difference and listen for the transition completion in JavaScript.
Note that we need to trigger this transition when the dialog is open but doesn’t have focus inside it.
dialog {
background-color: rgb(255, 255, 255);
}
dialog[open]:not(:focus-within) {
background-color: rgb(255, 255, 254);
transition: background-color 0.01s;
}
Let’s see what that CSS is doing.
- We put a
background-color
of our choice on the dialog. This isn’t necessary, but ensures we have the same background color across browsers. - The
dialog[open]:not(:focus-within)
selector applies when the dialog is open but doesn’t have focus on or inside it. This works because native element puts anopen
attribute when it’s open. - Inside this rule, we change the
background-color
by a minimum amount. This is the least change required to trigger a CSS animation and at the same time not causing any visual difference for the user (remember this is a dummy transition). Also, we set thetransition
property with a very small duration because we want it to finish as soon as possible and get detected in JavaScript.
A touch of JavaScript
Now, all we need to do is detect the end of our triggered CSS transition and focus back the first element inside the modal, like so:
modal.addEventListener('transitionend', (e) => {
modal.querySelector('input').focus();
});
We attach a transitionend
listener on the modal and inside the callback, we focus the first input inside the modal. Done!
Limitations
This is a quick experiment I did to create a working proof-of-concept of focus trapping with the :focus-within
pseudo-class. It has several limitations compared to dedicated JavaScript solutions to achieve this. Nevertheless, something is better than nothing!
Here are a couple of things this implementation lacks:
- According to W3C guidelines, the focus should cycle on the focusable element. But we are always focusing on the first
input
element. This is because without writing more JavaScript, we cannot know whether the focus was lost from the first or last element. - We are always focusing back to the first
input
element. But there are many more focusable HTML elements that might be present beforeinput
or maybe there is notinput
element at all inside the modal. Again, full-fledged JavaScript solutions detect and maintain a list of all focusable elements and focus the right one.
Better (JavaScript) implementations of focus trapping
- As mentioned earlier, one working example is available inside the W3C documentation itself.
- Here is another implementation by Rodney Rehm that also listens for tab.
- Greg Kraus has a library that achieves this. His implementation maintains a list of selectors for all valid focusable elements.
- One more lightweight library to create accessible modals.
That is all for this experiment. If you liked this trick, you can follow me on Twitter where I share more articles and side projects of mine.
The dialog element doesn’t work in Firefox or Safari or Edge, so the demo won’t be functional there. This is a fork that gets close:
I’m not even sure why it doesn’t fully work, maybe someone can take it an run with it.
Hey Chris, I have fixed the demos by adding polyfill for dialog. And added a disclaimer for Safari where even the normal tab navigation doesn’t work!
The
focusin
event bubbles so you can just do something like this.Interesting! That event (focusin) basically gives the JS alternative for my CSS
focus-within
approach.For the Safari standard keyboard navigation problems, have you made sure that in System Preferences->Keyboard->Shortcuts, Full Keyboard Access is set to “All Controls”?
Ahh so that was the problem. I didn’t knew about that setting. I’ll update the article. Thank you :)
Nice write up and very interesting experiment.
I didn’t think of
:focus-within
as something that can be used for the focus-trap.As you correctly said the most proper, according to the whole W3C requirements, way to do this is using JS.
So I’ve had no choice but to implement it using JS for my current and future projects.
https://goo.gl/g1A958
Nice article, looks like a good solution for the future. Currently I use 2 data-* attributes data-first and data-last to define start and end points and just listen to input and move last to first and vice versa for backwards navigation. Only requires and small amount of JS and gives complete control over order.
The blur event can be captured, the only trouble is to wait until it is processed completely and reset focus only after that.
Here’s the example fork: https://codepen.io/anon/pen/MVVqrq
Unfortunately, trapping focus is only part of the solution when it comes to making modals accessible. A modal dialog should prevent the user from interacting with things outside the modal – hence the purpose of your article. Users of screenreaders are still able to get access to underlying content using specific keystrokes that enable access to those things. Depending on the brand and version of their OS/ browser/ and screenreader, they could easily activate shortcuts to get to underlying form controls, headings, etc. which, depending on use case, you might want to prevent (otherwise why would there be a modal dialog in the first place, right?). The solution is quite easy: apply
aria-hidden="true"
to the content outside the dialog.This is actually a really good point, something I really hadn’t considered before. I’ve been doing a lot of work with trying to make accessible modals this past year, but never came across this. Great tip!
I recommend a11y-dialog 1, which is a cross-browser solution for modal dialogs that support focus trapping out of the box.
This is a pretty cool experiment though Karl is right on the a11y.
For myself, the Inert property (with a polyfill) is my go-to for handling modal interactions.