Creating a Keyboard Focus Trap

written by William Patton on December 3, 2024 in Web Development with no comments

Focus traps are an important—often misunderstood—keyboard navigation pattern. While their main appeal is improving accessibility, they also improve the user experience for everyone. They keep users where they want to be, avoiding confusion and disorientation.

This article is primarily about technical implementation and creating code that you can integrate into your projects. What they are and why you would use a focus trap will only be briefly touched on.

The full code, as a class, can be found towards the end of the article. I also added it as a new FocusTrap NPM package and created a FocusTrap GitHub repo. npm install @pattonwebz/focus-trap

What is a Focus Trap?

Person interacting with a preferences popup

A focus trap is a technique for keeping the keyboard focused within a specific region. When the focus is trapped, the user can only navigate through that region’s focusable elements (such as buttons, form fields, and links).

It keeps focus within the interactive area until they are ready to leave, either by completing the action or closing the element.

Common Use Cases:

  • Modals and Dialogs: These are the most common uses for a focus trap. Keep the focus within until the user submits or closes.
  • Dropdown Menus: Trap focus within the menu until selection or closure.
  • Lightboxes and Overlays: Ensure focus remains on media or interactive content.

How Does a Focus Trap Work?

Here’s a high-level breakdown of the logic a focus trap follows:

  • When initializing the focus trap for a region, get all focusable elements inside.
  • During navigation inside the region with the Tab key, intercept the event and check whether it moves the focus past the last focusable element or before the first. When it does, prevent the default behaviour and wrap the focus to the opposite end.
  • On closing the region, remove event listeners to clean up the focus trap, and then restore the focus to the original element that triggered the interaction.

Let’s look at some code to handle different parts of this. This will be minimal code to showcase a particular aspect. The complete code will be at the end of the article, which will use many of these snippets.

Identify Focusable Elements

First, get the region and identify all the focusable elements within it. These could be form fields, buttons, links, etc. We also want to find non-natively focusable elements that are made focusable with a non-negative tabindex.

const region = document.getElementByID('modal');
const focusableElements = region.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');

What if There are no Focusable Elements?

If no focusable elements are inside a region, then the region becomes the focused element.

focusableElements = (this.focusableElements.length)
	? this.focusableElements
	: [this.region];

Getting the First and Last Elements

To simplify managing focus, let’s find the first and last elements so we only intercept when they are the currently active items.

const firstFocusableElement = this.focusableElements[0];
const lastFocusableElement = this.focusableElements[this.focusableElements.length - 1];

Monitor Keyboard Navigation:

When a user presses the Tab key to navigate forward or Shift + Tab to navigate backwards, the focus trap ensures that the user can’t move outside the defined region. If the user tries to tab past the last focusable element or shift-tab past the first one, the focus wraps around to the opposite end of the region.

if (event.key === 'Tab') {
	if (event.shiftKey && document.activeElement === this.firstFocusableElement) {
		lastFocusableElement.focus();
		event.preventDefault();
	} else if (!event.shiftKey && document.activeElement === this.lastFocusableElement) {
		firstFocusableElement.focus();
		event.preventDefault();
	}
}

Starting the Focus Trap:

When the region is opened, the focus is typically set to the first focusable element, ensuring the user can immediately start interacting.

focusableElements[0].focus();

Return Focus on Close:

Once the modal or overlay is closed, the focus should return to the element that triggered its opening, maintaining a smooth and predictable navigation flow.

trigger.focus();

The Full Focus Trap Code

Below is the entire focus trap code. I implemented it as a class because that made sense to me from a reusability standpoint – the class can hold it’s own state entirely. You could quite easily refactor this to be a functional component instead if you wanted. It’s around 100 lines of code in total, even with docblocks and comments.

class FocusTrap {

  constructor(region, trigger = null, initialFocus = null) {
    this.region  = region;
    this.trigger = trigger;

    if (!(this.region instanceof HTMLElement)) {
      throw new Error('The region must be an HTMLElement.');
    }

    if (this.trigger && !(this.trigger instanceof HTMLElement)) {
      throw new Error('If passed the trigger must be an HTMLElement.');
    }

    this.focusableElements = this.region.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
    this.focusableElements = (this.focusableElements.length) ? this.focusableElements : [this.region];

    this.firstFocusableElement = this.focusableElements[0];

    this.lastFocusableElement = this.focusableElements[this.focusableElements.length - 1];

    this.initialFocus = initialFocus instanceof HTMLElement
      ? initialFocus
      : this.firstFocusableElement;

    if (this.initialFocus) {
      if (!this.focusableElements.includes(this.initialFocus)) {
        throw new Error('The initial focus element must be within the region.');
      }
    }
  }

  handleKeyDown = (event) => {
    if (event.key === 'Tab') {
      if (event.shiftKey && document.activeElement === this.firstFocusableElement) {
        this.lastFocusableElement.focus();
        event.preventDefault();
      } else if (!event.shiftKey && document.activeElement === this.lastFocusableElement) {
        this.firstFocusableElement.focus();
        event.preventDefault();
      }
    }
  }

  resume = () => this.region.addEventListener('keydown', this.handleKeyDown);

  pause = () => this.region.removeEventListener('keydown', this.handleKeyDown);

  activate = () => {
    this.resume();
    this.firstFocusableElement.focus();
  }

  deactivate = () => {
    this.pause();
    this.trigger?.focus();
  }
}

It has some functionality not outlined above. It has start, stop and pause capabilities. Pause is useful when you come up against a time you need to stack traps inside traps, it’s not a common situation but it does come up every so often.

// initialize and start the trap.
const trap = new FocusTrap( region, trigger );
// pause the trap.
trap.pause();
// restart it trap.
trap.resume();
// restart it shifting focus back to the first focusable element.
trap.start();
// stop the trap and if a trigger exists then move focus back to it.
trap.stop();

Wrapping Up

Focus traps are a small but crucial component in ensuring the web and other applications are accessible and user-friendly. Keeping focus within a specific region—whether it’s a modal, dropdown, or lightbox—provides a smooth and predictable experience for keyboard and screen reader users.

Focus traps prevent users from losing their place, improve accessibility compliance, and contribute to a more polished user experience.

By implementing a focus trap in your web applications, you can significantly improve usability for all users, especially those with disabilities, without sacrificing design or functionality.

Series Summary

I care about web accessibility. Thankfully, the people I spend my work day with care, too. I have learned a lot from them and applied that knowledge at work in many ways. This blog series explains how to tackle the most common remediations I deal with.

Anything without native browser handling can get somewhat complex – I will focus on those components throughout the series.