Working on a React app I had to implement a behaviour where tab navigation would loop between focusable elements inside a container. By default, web browsers switch the focus between all the interactable elements on page (such as <a>
, <input>
or containers with tabIndex
attribute) based on their position in DOM. Limitting the navigation “context” to a subset of DOM is called “focus trap”.
The task seemed trivial and I was hoping to find some generic single-function/component solutions on the web but, alas, only stumbled upon a couple of beefy npm packages and esoteric discussions.
So I’m sharing the solution I’ve come up with here; hopefully it will be useful as a starting point or inspiration for someone.
import { useRef, useEffect } from "react";
type Props = {
children: Array<JSX.Element | null>;
};
export const FocusTrap = (props: Props) => {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
let firstElement: (HTMLElement | undefined);
let lastElement: (HTMLElement | undefined);
if (ref.current != null)
for (const element of ref.current.querySelectorAll<HTMLElement>("[tabindex]"))
considerElement(element);
firstElement?.addEventListener("keydown", handleKeyOnFirst);
lastElement?.addEventListener("keydown", handleKeyOnLast);
return () => {
firstElement?.removeEventListener("keydown", handleKeyOnFirst);
lastElement?.removeEventListener("keydown", handleKeyOnLast);
};
function considerElement(element: HTMLElement) {
// @ts-ignore
if (!element.checkVisibility()) return;
if (firstElement === undefined) firstElement = element;
else lastElement = element;
}
function handleKeyOnFirst(event: KeyboardEvent) {
if (event.key !== "Tab" || !event.shiftKey) return;
event.preventDefault();
lastElement?.focus();
}
function handleKeyOnLast(event: KeyboardEvent) {
if (event.key !== "Tab" || event.shiftKey) return;
event.preventDefault();
firstElement?.focus();
}
}, [props.children]);
return <span ref={ref}>{props.children}</span>;
};
The trap component scans underlying content finding first and last visible elements with tabIndex
attribute and overrides Tab/Shift+Tab behavior for them. It can be used as follows:
<FocusTrap>
<ModalOrOtherContentToTrapFocus/>
</FocusTrap>
Be aware, that at the time of writing element.checkVisibility()
function is only available in Chrome, hence the @ts-ignore
suppression. Check the related StackOverflow thread for other options to check visibility of an element.
Comments