≡ Menu

Using JavaScript to trap focus in an element

Published on by Hidde de Vries in code

For information on how to accessibly implement the components I’m working on, I often refer to WAI-ARIA Authoring Practices specification. One thing this spec sometimes recommends, is to trap focus in an element, for example in a modal dialog while it is open. In this post I will show how to implement this.

Some history

In the early days of the web, web pages used to be very simple: there was lots of text, and there were links to other pages. Easy to navigate with a mouse, easy to navigate with a keyboard.

The modern web is much more interactive. We now have ‘rich internet applications’. Elements appear and disappear on click, scroll or even form validation. Overlays, carousels, AJAX… most of these have ‘custom’ behaviour. Therefore we cannot always rely on the browser’s built-in interactions to ensure user experience. We go beyond default browser behaviour, so the duty to fix any gaps in user experience is on us.

One common method of ‘fixing’ the user’s experience, is by carefully shifting focus around with JavaScript. The browser does this for us in common situations, for example when tabbing between links, when clicking form labels or when following anchor links. In less browser-predictable situations, we will have to do it ourselves.

When to trap focus

Trapping focus is a behaviour we usually want when there is modality in a page. Components that could have been pages of their own, such as overlays and dialog boxes. When such components are active, the rest of the page is usually blurred and the user is only allowed to interact with our component.

Not all users can see the visual website, so we will also need to make it work non-visually. The idea is that if for part of the site we prevent clicks, we should also prevent focus.

Some examples of when to trap focus:

In these cases we would like to trap focus in the modal, alert or navigation menu, until they are closed (at which point we want to undo the trapping and return focus to the element that instantiated the modal).

Requirements

We need these two things to be the case during our trap:

Implementation

In order to implement the above behaviour on a given element, we need to get a list of the focusable elements within it, and save the first and last one in variables.

In the following, I assume the element we trap focus in is stored in a variable called element.

Get focusable elements

In JavaScript we can figure out if elements are focusable, for example by checking if they either are interactive elements or have tabindex.

For this demo I’ll use jQuery. By all means, use plain JavaScript or a framework of your choice. In jQuery, this gives a list of common elements that are focusable:

var focusableEls = element.find('a, object, :input, iframe, [tabindex]');

Note that :input in jQuery refers to different input types, including textarea and select. Also be aware that this is an example list of common elements; there are many more focusable elements.

Save first and last focusable element

This is a way to get the first and last focusable elements within an element:

var firstFocusableEl = focusableEls.first()[0];  
var lastFocusableEl = focusableEls.last()[0];

The [0] gets the first item from what first() and last() return: they are jQuery objects, with [0] we get the DOM nodes. This is so that we can later compare them to document.activeElement, which contains the element in our page that currently has focus.

Listen to keydown

Next, we can listen to keydown events happening within the element , check whether they were TAB or SHIFT TAB and then apply logic if the first or last focusable element had focus.

var KEYCODE_TAB = 9;

$(element).on('keydown', function(e) {
    if (e.key === 'Tab' || e.keyCode === KEYCODE_TAB) {
        if ( e.shiftKey ) /* shift + tab */ {
            if (document.activeElement === firstFocusableEl) {
                lastFocusableEl.focus();
                e.preventDefault();
            }
        } else /* tab */ {
            if (document.activeElement === lastFocusableEl) {
                firstFocusableEl.focus();
                e.preventDefault();
            }
        }
    }
});

Alternatively, you can add the event listener to the first and last items. I like the above approach, as with one listener, there is only one to undo later.

Namespace the event

To make it easier to undo the focus trap, I have namespaced my event, like so:

$(element).on('keydown.' + namespace , function() {});

This will let me undoTrapFocus() using a simple function that takes the same element and namespace as its argument.

Putting it all together

With some minor changes, this is my final trapFocus() function:

function trapFocus(element, namespace) {
    var focusableEls = element.find('a, object, :input, iframe, [tabindex]'),
        firstFocusableEl = focusableEls.first()[0],
        lastFocusableEl = focusableEls.last()[0],
        KEYCODE_TAB = 9;

    $(element).on('keydown', function(e) {
        var isTabPressed = (e.key === 'Tab' || e.keyCode === KEYCODE_TAB);

        if (!isTabPressed) { 
            return; 
        }

        if ( e.shiftKey ) /* shift + tab */ {
            if (document.activeElement === firstFocusableEl) {
                lastFocusableEl.focus();
                e.preventDefault();
            }
        } else /* tab */ {
            if (document.activeElement === lastFocusableEl) {
                firstFocusableEl.focus();
                e.preventDefault();
            }
        }

    });
}

In this function, we have moved the check for tab to its own variable (thanks Job), so that we can stop function execution right there.

And this is my undoTrapFocus() function:

function undoTrapFocus(element, namespace) {
    $(element).off('keydown.' + namespace);
}

Further reading

Thanks Job, Rodney, Matijs and Michiel for your suggestions!

Comments & mentions (2)

Roel Van Gils 30 Jan 2017 13:57:48

Perhaps you should mention in the article that this technique only works reliably for (sighted) users who rely on keyboard navigation.

Screenreader users typically use a virtual focus to get around on a page. (⌃ + ⌘ + Arrow keys when using Voiceover, for example).

Johan Ronsse 01 Feb 2017 12:50:23

Very interesting.

My theory is that if what is “behind” the { modal/popover/interactive element that has its own focus cycle} can be put in a separate container, and we temporarily remove it from the DOM flow, we can work around this whole problem.

A screenreader wouldn’t even see it.

But that’s just an idea. I have some more reading to do.

Leave a comment
Posted a response to this?

This website uses Webmentions. You can manually notify me if you have posted a response, by entering the URL below.