A CSS Approach to Trap Focus Inside of an Element

Avatar of Kushagra Gour
Kushagra Gour on (Updated on )

📣 Freelancers, Developers, and Part-Time Agency Owners: Kickstart Your Own Digital Agency with UACADEMY Launch by UGURUS 📣

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.

Enter :focus-within

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:

  1. trigger a CSS transition
  2. detect that transition completion in JavaScript
  3. 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.

  1. 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.
  2. 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 an open attribute when it’s open.
  3. 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 the transition 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:

  1. 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.
  2. We are always focusing back to the first input element. But there are many more focusable HTML elements that might be present before input or maybe there is not input 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

  1. As mentioned earlier, one working example is available inside the W3C documentation itself.
  2. Here is another implementation by Rodney Rehm that also listens for tab.
  3. Greg Kraus has a library that achieves this. His implementation maintains a list of selectors for all valid focusable elements.
  4. 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.