DEV Community

Cover image for Focus Trapping - Trap focus inside an element
Rahul Kumar
Rahul Kumar

Posted on

Focus Trapping - Trap focus inside an element

In this article, I will cover one of the important aspects of making a web-page accessible - trapping/looping (will call it looping from here on) focus inside a DOM element, scenarios in which it's required and a JavaScript utility to achieve it.
In the last section, I will discuss the nuances of the suggested solution on a mobile-device with screen-reader enabled.

Some words on the problem that we want to fix

Keyboard navigation is a basic requirement in making any product accessible. In a web-page that's easily achieved by using a combination of semantic HTML, tabIndex, aria attributes and some JS.

If you are aware of the problem, you may skip to the next section. For details, continue reading.

Let's take an example. Imagine a login page with both sign-in and sign-up buttons. Clicking any would open up a modal with fields required for the respective action. For a correct keyboard navigation -

  1. When the modal opens, the first input element (which element should get the focus depends on the page's interaction design) should get the focus.
  2. The user should be able to tab to the other interactable elements inside the modal.
  3. On tabbing the focus shouldn't go out of the modal or rather should loop inside

As natural as the above interactions may sound, it doesn't behave that way by default. That's because modal or similar UI elements are only virtual boundaries. Tabbing works on DOM's natural order.

Thus, on opening the modal, the modal elements won't even receive the focus (if modal is not positioned absolutely to the element that triggered it), let alone the focus looping inside.

The utility to solve this problem

I will stop wasting words and present the code that solves the problems for us:

tl;dr

Here's the JS snippet that does the main task of trapping the tabbed navigation. This snippet can be found from line 47 to 62 in the gist that follows the snippet.

function keyboardHandler(e) {
  if (e.keyCode === 9) {
    //Rotate Focus
    if (e.shiftKey && document.activeElement === firstFocusableEl) {
      e.preventDefault();
      lastFocusableEl.focus();
    } else if (!e.shiftKey && document.activeElement === lastFocusableEl) {
      e.preventDefault();
      firstFocusableEl.focus();
    }
  }
};
el.addEventListener('keydown', keyboardHandler);
Enter fullscreen mode Exit fullscreen mode

In the code above, firstFocusableEl & lastFocusableEl are (as the names suggest) the first and the last focusable elements within the container element.

For a detailed and production ready code, please take a look at the following gist.

Does it work on mobile screen-readers?

Well, No!

If you have worked on web accessibility on mobile devices (Android or iOS), you would know that on turning on the screen-reader (Talkback and Voiceover) you get swipe and double tap actions.

If you are thinking that we can modify our logic to also listen to this swipe gesture, then you should know that it won't work as the swipe gesture doesn't trigger any event that we can catch in a web-page.

How do we fix it then?

There's a clever solution for that. Hide all the elements except the container element (passed to loopFocus()) from the screen-reader. That can be done by adding aria-hidden=true attribute to all elements except the container element.

This will turn out to be an inefficient implementation if there are a lot of elements to be hidden. For that, you should know one thing about screen-readers and aria-hidden that when a container is marked hidden, all its children are inaccessible to the screen-reader. Use this information and try to have a structure such that the higher level elements in the hierarchy are marked hidden. Remember, the idea is to reduce the number of DOM manipulations.

Last words

Focus looping is an important part of A11Y support. Even when you are not planning on making your page fully accessible but only want it to be keyboard operable.

If you have any question on A11Y or want to discuss a problem, do post your comment and we shall take the discussion forward. Let's together make the web inclusive and usable for everyone.

Top comments (3)

Collapse
 
davevasquez profile image
David Vasquez

Rahul,

Great article and interesting execution! I have one question - could you elaborate a bit more on the cleanUp function, its expected usage, and your thinking behind it?

Thanks!

Collapse
 
maswerdna profile image
Samson Andrew

@david,
Setting up multiple events listeners on a document can have a performance impact on the document, therefore it is advisable to remove listeners when they are not in use. (It also helps to prevent memory leakage).

It can be used in this way:

    let signupModalCleaner

    modalControlButton.onclick = () => {
        if (open) {
            signupModalCleaner()
            open = false
        } else {
            signupModalCloser = loopFocus(config)
            open = true
    }
Enter fullscreen mode Exit fullscreen mode
Collapse
 
maswerdna profile image
Samson Andrew

@david,
Setting up multiple events listeners on a document can have a performance impact on the document, therefore it is advisable to remove listeners when they are not in use. (It also helps to prevent memory leakage).

It can be used this way:

    let signupModalCleaner

    modalControlButton.onclick = () => {
        if (open) {
            signupModalCleaner()
            open = false
        } else {
            signupModalCloser = loopFocus(config)
            open = true
    }
Enter fullscreen mode Exit fullscreen mode