In certain occasions it's needed to trap focus inside a specific element, meaning that pressing TAB or moving the screen reader cursor will not exit the element, but cycle only inside of it.
Think about modals, for example: they float over your page, as pages by themselves. Their role is to interrupt your flow for a very specific message or task, allowing you to continue only after completion. If you were able to navigate and interact with the page beneath, there should be no point in presenting the modal, right?
Visually this is achieved by partially hiding the page beneath with an overlay, that blurs or covers the contents and prevents mouse interaction, suggesting in this way that what's on top should have priority. But this is easily breakable if you navigate with your keyboard: pressing TAB you can easily skip the modal and return to focusing page elements.
You can test this kind of trapping/looping behaviour even in application menus: try to open a “File” menu in whatever application and cycle between options with your arrow keys. Focus will not leave the dropdown until you press Escape.
This feature is called “focus trapping” or “focus looping”: I think the latter is more appropriate since when dealing with "focus traps" you should always specify if the trap is a desired behaviour or represents a bug.
Even the recently added <dialog>
tag doesn't apply any "focus looping" strategy: every element boundaries are only theoretical, virtual, and don't modify TAB order or behaviour at all. So, we'll have to do it by hand.
Focus looping - first approach
For properly "closing the loop" we will need to find the first and the last focusable elements and apply to them an EventListener
that intercepts keystrokes and, eventually, forces focus to cycle between the two instead of falling out.
Let's define a constant for focusable elements selection.
const FOCUSABLES = 'a[href]:not([disabled]), area[href]:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, [tabindex="0"], [contenteditable]';
With the help of the following code, we will have a list of all focusable elements inside the container.
let focusableElelements = container.querySelectorAll(FOCUSABLE);
Here's the key concept: when the user will press TAB or SHIFT + TAB we will check if the event.target
(the element where the event has happened, to briefly describe it) is the first of our focusable elements (focusableElements[0]
) or the last (focusableElements[focusableElements.length - 1]
, this "-1" is because arrays start with the "0" key) and eventually prevent the default behavior of the key (event.preventDefault()
) moving the focus programmatically to the first/last focusable element.
Easy enough?
A couple of details
In the pen you'll see that I've added the eventListener
to the container, rather than the two elements, leaving room to adapt to dynamic addition-removing of focusable elements. Just refresh the list and you're good to go, instead of removing and re-adding listeners specifically to an element.
The triggering event here is "keyDown
": since keyDown
happens before keyUp
events and the two are not linked, it would have required a more complex approach. By the way, if you try keeping TAB pressed, this is also the usual behaviour (rapid repetition at key kept pressed).
Focus looping - advanced approach
As you may notice, we're dealing only with the TAB key. This is a detail of no secondary importance: while the focus loops correctly only inside the container, this is not a real boundary for arrow keys when using assistive technologies!
There are some techniques to accomplish the same behaviour also for arrow keys. We have cited a couple of common cases of focus trapping uses, thus you have to choose the right one for your specific case.
submenus: you could think to move focus with your arrow keys to mirror a common menu behaviour;
modals and alerts: you can work with both code position and
aria-hidden
attribute. Positioning the modal/alert as a sibling of the main content and specifyingaria-hidden="true"
for the main content you virtually remove "everything else" than modal from the content.
Don't worry, we will talk in detail about these in their dedicated articles.
Looping on mobile screen-readers
On mobile all this will not work... Hooray!
Turning on the screen reader (Talkback and Voiceover) and swiping you can focus next/prev element in focus order, but you don't trigger the keyDown
event. Well, swiping doesn't trigger any event at all!
Luckily, what we've seen a few lines above is valid also for this specific case! Aria-hidden
to the rescue. However, you'll have to evaluate how to manage your code accordingly: modals and alerts will be ok, but for menus this could become a potentially impactful decision.
A nice utility listener: rebounce focus
In the final pen I decided to add a small goodie, useful on many occasions when dealing with focus looping. In many cases you have to automatically focus the first focusable element: modals, dropdown menus... Without the hassle of traversing the DOM just for this task, you can now move focus to the container (a tabindex="-1"
is automatically added) and the container will rebounce it immediately to its first focusable element.