Photo of a computer keyboard with the tab and escape buttons in focus. The keyboard is overlapping a blank post-it note.
Photo By Jeffery Betts

Create a reusable focus lock in React to improve user experience and accessibility

James Boatwright
Tamman Inc.

--

Create a reusable React focus lock to improve user experience

My name is James, and I’m an accessibility focused web developer at Tamman Inc, based in Old City, Philadelphia. Today, I want to talk about handling browser focus, particularly in sites built using the React library.

To ensure your sites are operable for all users, it’s essential that you create a tab flow that allows keyboard-only users to access everything a user with a mouse could. For most situations, the browser’s default handling of tab flow provides an adequate experience for users. However, there are certain interfaces, such as menus or dialog boxes, where the default practice can lead to a poor user experience. For example, if a user is focused on an input inside of a dialog and begins to tab through, it’s possible for the user to step out of the modal and get lost in the site. We can address this by creating a focus lock. When a focus lock is in use, the user will only be able to tab through focusable items inside of the lock.

While working with React sites in the past, I found myself redoing the same focus management across different projects consequently creating more work for myself. To cut down on the amount of repeated work, I combined some of the focus lock techniques I’ve used into a bespoke container component, that either myself or another dev could drop in our projects. I’d like to share the structure of this component so that you can use it in your own projects.

One point to note: this container component will not interfere with how a screen reader steps through a page. Adjusting the flow of a screen reader would involve different strategies that deserve an article of their own. For this component, we’re going to concentrate purely on best practices for handling focus.

Starting our component

We’ll start by building a basic container element that our child nodes will be able to pass through. We’ll destructure the children from our other properties, and then spread those other properties onto the parent div, so that no other values, like additional classes, are lost.

import React from 'react'

const FocusLock = ({ children, ...otherProps }) => {

return (
<div {...otherProps}>
{ children }
</div>
)
}

export default FocusLock

Adding refs

Next, we’ll create two refs that will make our data flow more manageable later on. The first will be a ref to the rootNode, so that we have quick access to our root node without having to run a query each time. The second ref will be for an array of focusable dom nodes that will be filled later.

import React, { useRef } from 'react'

const FocusLock = ({ children, ...otherProps }) => {
const rootNode = useRef(null)
const focusableItems = useRef([])

return (
<div {...otherProps} ref={rootNode}>
{ children }
</div>
)
}

export default FocusLock

Finding all the focusable children

We want to know all items inside of our focus lock that are focusable. To do this, we’re going to run a querySelectorAll on our rootNode ref, searching for commonly focusable elements like buttons, links, inputs, etc. Feel free to update this query if you run into a gap on what is being selected. We’ll then update our focusableItems ref with the focusable elements.

import React, { useEffect, useRef } from 'react'

const FocusLock = ({ isLocked = true, children, ...otherProps }) => {
const rootNode = useRef(null)
const focusableItems = useRef([])

useEffect(() => {
const updateFocusableItems = () => {
focusableItems.current = rootNode.current.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]), video')
}
updateFocusableItems()
}, [rootNode])

return (
<div {...otherProps} ref={rootNode}>
{ children }
</div>
)
}

export default FocusLock

We now have an array of all the focusable items nested inside our container component, but what if focusable items are added or removed later? We want to be able to update our list to catch these changes. To do this, we’re going to add an observer.

import React, { useEffect, useRef } from 'react'

const FocusLock = ({ isLocked = true, children, ...otherProps }) => {
const rootNode = useRef(null)
const focusableItems = useRef([])

useEffect(() => {
const updateFocusableItems = () => {
focusableItems.current = rootNode.current.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]), video')
}

const observer = new MutationObserver(() => {
updateFocusableItems()
})
updateFocusableItems()
observer.observe(rootNode.current, { childList: true })
return () => {
observer.disconnect()
}
}, [rootNode])

return (
<div {...otherProps} ref={rootNode}>
{ children }
</div>
)
}

export default FocusLock

The Mutation observer is set to watch our rootNode for any changes to the childlist and rerun our selector when it detects a change. This will account for situations where we have an expanding menu and new link options are added and removed. We’re also going to make sure we disconnect our observer after the component has been cleared to prevent any potential weirdness through zombie processes.

Setting up the keyboard listeners

When the focus lock is working, we will want a user to be able to tab through the contents of the lock normally. The only time we would want to change the default behavior is if the user is about to tab out of the lock, by either tabbing forward while on the last focusable item or tabbing back on the first. In those cases, we would want our tabbing to wrap around, bringing the user to the first item when tabbing forward and the last item when tabbing back. Now that our component knows what is focusable, we need to be able to observe and transform a user’s keyboard interactions so that the focus lock operates in the expected manner.

First, let’s add a new optional property called isLocked to the component. This property will default to true for instances when the focus lock is only rendered when needed, but if there is a situation where the lock needs to be turned off, it can be done so by the parent changing isLocked to false.

const FocusLock = ({ isLocked = true, children, …otherProps }) => {

Next, we’re going to create another effect that is dependent on focusableItems and isLocked. In this effect, we’ll be setting up a keyDown listener that triggers our call back. The callback will only do something during four scenarios while the lock is engaged.

  • If focusableItems is empty, then we are going to return out of the callback.
  • If there is only one focusableItem when tab is pressed, then we will prevent the default tabbing, keeping tab on the same item.
  • If tab is pressed while the last focusableItem is the activeElement, then we set focus to the first focusableItem.
  • If tab and shift are pressed while the first focusableItem is the activeElement, then we set focus to the last focusableItem.
useEffect(() => {
const handleKeyPress = event => {
if (!focusableItems.current) return

const { keyCode, shiftKey } = event
const {
length,
0: firstItem,
[length - 1]: lastItem
} = focusableItems.current

if (isLocked && keyCode === TAB_KEY ) {
// If only one item then prevent tabbing when locked
if ( length === 1) {
event.preventDefault()
return
}

// If focused on last item then focus on first item when tab is pressed
if (!shiftKey && document.activeElement === lastItem) {
event.preventDefault()
firstItem.focus()
return
}

// If focused on first item then focus on last item when shift + tab is pressed
if (shiftKey && document.activeElement === firstItem) {
event.preventDefault()
lastItem.focus()
return
}
}
}

window.addEventListener('keydown', handleKeyPress)
return () => {
window.removeEventListener('keydown', handleKeyPress)
}
}, [isLocked, focusableItems])

The entire component code can be seen below.

import React, { useEffect, useRef } from 'react'

const TAB_KEY = 9

const FocusLock = ({ isLocked = true, children, ...otherProps }) => {
const rootNode = useRef(null)
const focusableItems = useRef([])

useEffect(() => {
const updateFocusableItems = () => {
focusableItems.current = rootNode.current.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]), video')
}

const observer = new MutationObserver(() => {
updateFocusableItems()
})
updateFocusableItems()
observer.observe(rootNode.current, { childList: true })
return () => {
observer.disconnect()
}
}, [rootNode])

useEffect(() => {
const handleKeyPress = event => {
if (!focusableItems.current) return

const { keyCode, shiftKey } = event
const {
length,
0: firstItem,
[length - 1]: lastItem
} = focusableItems.current

if (isLocked && keyCode === TAB_KEY ) {
// If only one item then prevent tabbing when locked
if ( length === 1) {
event.preventDefault()
return
}

// If focused on last item then focus on first item when tab is pressed
if (!shiftKey && document.activeElement === lastItem) {
event.preventDefault()
firstItem.focus()
return
}

// If focused on first item then focus on last item when shift + tab is pressed
if (shiftKey && document.activeElement === firstItem) {
event.preventDefault()
lastItem.focus()
return
}
}
}

window.addEventListener('keydown', handleKeyPress)
return () => {
window.removeEventListener('keydown', handleKeyPress)
}
}, [isLocked, focusableItems])

return (
<div {...otherProps} ref={rootNode}>
{ children }
</div>
)
}

export default FocusLock

Now, we can import our focus lock into another component for use. Most likely, we would have the lock rendered behind a conditional. For example, if we have a variable driving whether or not a dialog would display, it would look something like this.

{ showDialog && <FocusLock><Dialog /></FocusLock> }

Or, if you’re using it as part of a menu that’s controlled by a toggle button:

<FocusLock isLocked={isMenuOpen}>
<button onClick={() => setIsMenuOpen(!isMenuOpen)}>
{ isMenuOpen && <Menu /> }
</FocusLock>

One thing to keep in mind, this lock will not set focus inside the lock when turned on or return focus once the lock is closed. That functionality should be handled by the parent component that is driving our logic. Additionally, users should be able to exit a focus lock by pressing the escape button, but I feel like controlling this should also be controlled by the parent component.

Finishing Up

We now have a focus lock container component that can be reused throughout our projects. Additionally, as best practices evolve in both development and accessibility, when we find a new way to improve the lock, we only have to change it in one place. Building and using accessibility minded components like our Focus Lock is a great way to not only save development time for yourself, but also to guarantee a better, more operable experience for your users.

--

--