Trap keyboard focus in an accessible element

Trying to build accessible components like modals, dialogs or other types of overlaying elements can be daunting. ARIA-attributes can help with assistive technologies, but will not prevent tabbing outside of the active element.


Until the <dialog> element are fully supported, you might want to resort to building accessible modals/dialogs yourself. This means handling state and input limitations. For any other non-dialog component you’re probably on your own as well.

For example, setting aria-modal="true" on an active modal will tell assistive technologies that the windows underneath the current dialog are not available for interaction1. This, however, will not prevent the user from tabbing outside of your modal. In this case we’ll have to resort to JavaScript and trap the keyboard focus, and keep the tab in our DOM element.

It’s one of those harder things to do — but important nonetheless.


trapFocus()

We’ve built a small function to trap keyboard focus. There are different approaches to this. A common one is to check wether the user is trying to focus beyond the ‘last’ focusable element, and programmatically refocus on the first one.

We’re using a variation where we disable the tabindex of all focusable elements outside of the one we’re trying to trap. This prevents you from having to resort to event listeners and having to handle initially disabled elements which might become enabled later on. It also allows you to still reach the ‘chrome’ of the browser, and is a bit more generic and future proof when used with shadow DOM and web components2.

const CANDIDATES = `
  a, button, input, select, textarea, svg, area, details, summary,
  iframe, object, embed, 
  [tabindex], [contenteditable]
`;

const trapFocus = (focusNode, rootNode = document) => {
  const nodes = [...rootNode.querySelectorAll(CANDIDATES)]
    .filter(node => !focusNode.contains(node) && node.getAttribute('tabindex') !== '-1');
  nodes.forEach(node => node.setAttribute('tabindex', '-1'));
  return {
    release() {
      nodes.forEach(node => node.removeAttribute('tabindex'));
    },
  };
};

What does it do?

  • It adds tabindex="-1" to any focusable DOM node not inside the focusNode. This prevents elements from gaining focus.
  • It leaves existing tabindex="-1" nodes intact.
  • Allows release of the trap by calling release().

Usage

const modal = document.querySelector(`[aria-modal="true"]`);
const focusTrap = trapFocus(modal); // focus is trapped

focusTrap.release(); // focus is released

Caveats & Notes

  • The function does not incorporate an initial focus() and possible blur() on the first focusable element inside your trapped focus element and will not ‘cycle’ from last to first.
  • Elements are not ‘visually hidden’ for screen readers (e.g. aria-hidden="true").
  • Adding tabindex="-1" allows you to ‘focus’ the node with the mouse, which adds an outline to it. This can fixed by adding a backdrop, disabling elements with pointer-events: none, or removing the outline.
  • It doesn’t preserve existing tabindex values. While it does exclude -1, it could overwrite elements with 0. Setting it to anything other than those two is considered bad practice. Store and restore is this poses a problem.
  • In the future you might want to switch to the more semantic inert attribute.
  • This snippet contains untranspiled JavaScript, you’ll need to transpile it yourself, probably in a build step.

Finally

The method is available in @grrr/utils, and has a slightly more extensive API.

Require it from npm:

$ npm install @grrr/utils

Import the function from the package, and use it as described above:

import { trapFocus } from '@grrr/utils';