Back in 2020, I wrote about creating a reusable React component that helped us manage focus (opens in a new tab) when a user was interacting with complex UI elements such as dialogs and pop-up menus. The problem we were running into was when somebody who used keyboard navigation opened a dialog, they would be able to keep pressing the Tab key and step out of the dialog and interact with items on the page that were visually hidden or, worse, get locked out of key functionality of the site. We wanted a solution that could be reusable across multiple properties without needing a lot of integration adjustments, so I wrote a React component that would track when a user presses the Tab key and loop the focus through elements contained in the component. The component worked well for us. We were able to incorporate it into our design system library, specifically in our modal and mobile menu components, allowing us to quickly create accessible experiences without having to reinvent the wheel for each project. Still, that component was written several years ago and was due for a bit of refactoring to improve stability and general compliance.
Originally, the component was called FocusLock, but I wanted to change it to FocusBoundary. I wish I could say there was a profound reason for this change, but honestly, I just thought Boundary sounded fancier.
The first thing I wanted to do was make sure that the updated FocusBoundary component was written in TypeScript. The original component, as well as most of our design system, was written in JavaScript, so there were not many opportunities to work in TypeScript. Besides the benefits of making the component strongly typed, it gave me a chance to do more work in TypeScript and get a bit more comfortable with it.
To start, we'll create the component. The only unique parameter we need for the component is a boolean isLocked that defaults to false. Originally, we defaulted isLocked to true, but coming back to it, I chose to make the default state as least disruptive as possible. Like the original, the parent component is responsible for changing the state of the boundary, and also setting the initial focus on lock and returning focus on unlock. While the component handles focus when the user is actively focused on an element inside the boundary, I wanted to leave it agnostic in regard to the initial focus when the boundary is active and what's focused after it's deactivated.
import { forwardRef, useRef } from 'react'
import type { ComponentPropsWithoutRef } from 'react'
export interface FocusBoundaryProps extends ComponentPropsWithoutRef<'div'> {
isLocked?: boolean
}
export const FocusBoundary = forwardRef<HTMLDivElement, FocusBoundaryProps>(
({ isLocked = false, children, ...otherProps }, ref) => {
return (
<div {...otherProps}>
{children}
</div>
)
},
)
FocusBoundary.displayName = 'FocusBoundary'Like with the previous version, we're creating a component that wraps the children in a div and assigns a ref to that particular div. I wanted to allow users to have a little more flexibility, so I wrapped the component in forwardRef so an outside ref can be used when necessary. This means we'll want to use ComponentPropsWithoutRef as the type for our component props and then use <HTMLDivElement, FocusBoundaryProps> for the component. This version of the component is written for React 18, but React 19 will allow a ref to be passed through as a regular prop instead of requiring forwardRef (opens in a new tab), so at some point we may want to come back and match the new code style.
Next, we'll need to handle both our internal ref and the consumer forward ref. We'll create our internal ref of rootNode using useRef, but instead of following the pattern ref={rootNode} like we did in the previous version, we're going to write a callback to handle both refs.
import { forwardRef, useRef, useCallback } from 'react'
import type { ComponentPropsWithoutRef } from 'react'
export interface FocusBoundaryProps extends ComponentPropsWithoutRef<'div'> {
isLocked?: boolean
}
export const FocusBoundary = forwardRef<HTMLDivElement, FocusBoundaryProps>(
({ isLocked = false, children, ...otherProps }, ref) => {
const rootNode = useRef<HTMLDivElement | null>(null)
// Keep our own ref for the trap and pass the same node to the consumer's ref
const setRootNode = useCallback(
(node: HTMLDivElement | null) => {
rootNode.current = node
if (typeof ref === 'function') ref(node)
else if (ref) ref.current = node
},
[ref],
)
return (
<div {...otherProps} ref={setRootNode}>
{children}
</div>
)
},
)
FocusBoundary.displayName = 'FocusBoundary'We create our internal ref of rootNode and assign it as null initially and then invoke the callback setRootNode from the component ref attribute. When setRootNode is called, it assigns the container node to the internal ref and, when the forward ref is a function, also assigns it to the forward ref. Now our code will be able to work as expected and users can do any other ref-related work they need.
Next, we're ready to create our keyboard listeners. Like the previous version, we're going to add a useEffect call with a dependency of isLocked to assign our keyboard listener and also clean up when the component is unmounted.
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
// TODO Add keypress handling
}
window.addEventListener('keydown', handleKeyPress)
return () => {
window.removeEventListener('keydown', handleKeyPress)
}
}, [isLocked])Since we're applying the keydown listener to the window object, we're going to use the native KeyboardEvent typing for the keydown event. Next, we'll get what key is being pressed and if the Shift key is also pressed. The Shift key is important because it tells us which direction the user is stepping through while changing focus. Before we get into the weeds on handling the key event, let's add some basic guards.
const TAB = 'Tab'
// Skipping some lines
const handleKeyPress = (event: KeyboardEvent) => {
const { key, shiftKey } = event
const root = rootNode.current
if (!isLocked || !root) return
if (key !== TAB) return
}If the boundary isn't locked or if rootNode ref is for some reason missing, then we don't need to do anything so we can return out of the handler. Also, if the user didn't press the Tab key, then we really don't care what's happening, so we also return out if the key is not Tab. You can either write out 'Tab' in the conditional or use a constant like I'm doing here.
Next, we're going to need to grab all of the tabbable elements inside the container ref. In the older version, I used querySelectorAll to find the focusable items and a MutationObserver to look for changes and update the list of focusable items when necessary. This ended up being a limiting solution because the observer would miss certain changes, like an input being disabled or having its tabindex altered, or anything changing deeper in the DOM. This was a heavy and imperfect solution for an interaction that happens at human speed. I ended up choosing to rerun the query during the callback since it wouldn't be too resource intensive.
Originally, I used this string of element types to query for focusable items.
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]), video'
This query worked for us for a very long time, but it had some gaps: for example, it could focus a video element without controls, and it missed audio elements with controls. To make sure our query was more robust, I brought in the Tabbable (opens in a new tab) library to handle getting all of the focusable items. Additionally, they have their own scripts and components for handling a focus trap (opens in a new tab) that have a few more features than what we're building here and would be a good resource to check out.
const focusableItems = tabbable(root)
const firstFocusableItem = focusableItems[0]
const lastFocusableItem = focusableItems.at(-1)
if (!firstFocusableItem || !lastFocusableItem) returnHere we are getting the focusable items in the container and then getting both the first and last focusable item, because ultimately those two items are all we care about. If for some reason we're missing those, then we return out of the handler.
Finally, we add our conditional logic for rerouting the Tab focus. When a user is focused on the last focusable item without pressing the Shift key, focus is set to the first focusable item. Conversely, when the first item is focused and the user presses Shift+Tab, we move focus to the last item. Because of how we're now getting the focusable items, we do not need a special condition to handle when there is only one focusable item. The first and last focusable items will be the same, so the logic will still work as expected.
if (!shiftKey && document.activeElement === lastFocusableItem) {
event.preventDefault()
firstFocusableItem.focus()
} else if (shiftKey && document.activeElement === firstFocusableItem) {
event.preventDefault()
lastFocusableItem.focus()
}And our complete component looks like this.
import { forwardRef, useCallback, useEffect, useRef } from 'react'
import type { ComponentPropsWithoutRef } from 'react'
import { tabbable } from 'tabbable'
const TAB = 'Tab'
export interface FocusBoundaryProps extends ComponentPropsWithoutRef<'div'> {
isLocked?: boolean
}
export const FocusBoundary = forwardRef<HTMLDivElement, FocusBoundaryProps>(
({ isLocked = false, children, ...otherProps }, ref) => {
const rootNode = useRef<HTMLDivElement | null>(null)
// Keep our own ref for the trap and pass the same node to the consumer's ref
const setRootNode = useCallback(
(node: HTMLDivElement | null) => {
rootNode.current = node
if (typeof ref === 'function') ref(node)
else if (ref) ref.current = node
},
[ref],
)
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
const { key, shiftKey } = event
const root = rootNode.current
if (!isLocked || !root) return
if (key !== TAB) return
// tabbable returns elements in the browser's actual Tab order, excluding
// disabled, hidden, inert and tabindex="-1" elements and unselected radios
const focusableItems = tabbable(root)
const firstFocusableItem = focusableItems[0]
const lastFocusableItem = focusableItems.at(-1)
if (!firstFocusableItem || !lastFocusableItem) return
if (!shiftKey && document.activeElement === lastFocusableItem) {
event.preventDefault()
firstFocusableItem.focus()
} else if (shiftKey && document.activeElement === firstFocusableItem) {
event.preventDefault()
lastFocusableItem.focus()
}
}
window.addEventListener('keydown', handleKeyPress)
return () => {
window.removeEventListener('keydown', handleKeyPress)
}
}, [isLocked])
return (
<div {...otherProps} ref={setRootNode}>
{children}
</div>
)
},
)
FocusBoundary.displayName = 'FocusBoundary'And here is how you would integrate the focus boundary into an existing React component.
import { FocusBoundary } from 'focus-boundary'
import { useState, type KeyboardEvent } from 'react'
// While locked, Tab and Shift+Tab wrap between the button and the two fields.
export function FocusBoundaryDemo() {
const [isLocked, setIsLocked] = useState(false)
// A keyboard way out besides the button (WCAG 2.1.2: No Keyboard Trap).
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (isLocked && event.key === 'Escape') setIsLocked(false)
}
return (
<FocusBoundary
isLocked={isLocked}
onKeyDown={handleKeyDown}
>
<p aria-live="polite">
{isLocked
? 'Focus is locked. Tab stays inside this box. Press Escape or Unlock to leave.'
: 'Focus is unlocked. Tab moves on to the rest of the page.'}
</p>
<button type="button" onClick={() => setIsLocked(!isLocked)}>
{isLocked ? 'Unlock' : 'Lock'} focus
</button>
<div>
<label htmlFor="name">Name</label>
<input id="name" type="text" />
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" />
</div>
</FocusBoundary>
)
}Examples
You can see a working example here. In this demo, when a user toggles the button to enable the boundary, the boundary keeps the focus contained to the component.
Basic focus boundary demo
Focus is unlocked. Tab moves on to the rest of the page.
When I wrote the first focus boundary, we were primarily integrating it with a hand-spun dialog component that required a bunch of role and ARIA declarations to be accessible in the intended way and also handle screen reader navigation. While working on the update to the boundary, I wanted to try integrating it with the native HTML dialog element.
import { FocusBoundary } from 'focus-boundary'
import { useRef, useState } from 'react'
// showModal() blocks the rest of the page, and FocusBoundary keeps Tab wrapping inside the
// dialog rather than escaping to the browser's address bar.
export function DialogDemo() {
const [isOpen, setIsOpen] = useState(false)
const dialogRef = useRef<HTMLDialogElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const firstFieldRef = useRef<HTMLInputElement>(null)
const open = () => {
dialogRef.current?.showModal()
firstFieldRef.current?.focus()
setIsOpen(true)
}
const close = () => dialogRef.current?.close()
// Fires for every way the dialog closes, including Escape
const handleClose = () => {
setIsOpen(false)
triggerRef.current?.focus()
}
return (
<>
<button ref={triggerRef} type="button" onClick={open}>
Edit profile
</button>
<dialog
ref={dialogRef}
aria-labelledby="dialog-title"
onClose={handleClose}
>
<FocusBoundary isLocked={isOpen}>
<h2 id="dialog-title">
Edit profile
</h2>
<div>
<label htmlFor="dialog-name">Name</label>
<input ref={firstFieldRef} id="dialog-name" type="text" />
</div>
<div>
<label htmlFor="dialog-email">Email</label>
<input id="dialog-email" type="email" />
</div>
<div>
<button type="button" onClick={close}>
Cancel
</button>
<button type="button" onClick={close}>
Save
</button>
</div>
</FocusBoundary>
</dialog>
</>
)
}The boundary slots in perfectly with the native dialog element. We wrap all of the contents of the dialog in the boundary and when the dialog trigger is pressed, it updates the parent component's state to turn on the boundary and then focuses on an element in the boundary. When closed, state changes and disables the boundary and returns focus to the dialog initiator.
Modal dialog demo
I also used the boundary in the lightbox component that I made for this site.
You can use this guide to make your own focus boundary, or use this version of FocusBoundary from npm (opens in a new tab). FocusBoundary is also available on my GitHub account (opens in a new tab). Thank you for reading!
