How to Use the useFocusTrap Hook for Accessibility in Astryx
The useFocusTrap hook implements the WAI-ARIA dialog focus-trap pattern, ensuring keyboard users cannot tab out of modals or popovers while providing built-in Escape key handling and circular tab navigation.
The useFocusTrap hook is a core accessibility utility in the facebook/astryx design system that manages keyboard focus within interactive containers. It is essential for building compliant dialogs, popovers, and any overlay that requires users to complete an action before returning to the main content. This guide covers the hook's architecture, integration patterns, and implementation examples based on the source code in packages/core/src/hooks/useFocusTrap.ts.
Core Architecture of useFocusTrap
The hook combines several accessibility mechanisms to create a robust focus management system. According to the Astryx source code, it detects focusable elements, manages initial focus, intercepts keyboard navigation, and handles the Escape key.
Focusable Element Detection
The hook uses a constant FOCUSABLE_SELECTOR to identify interactive elements within a container. This selector targets buttons, links, inputs, selects, textareas, and any element with a tabindex not equal to -1. The helper function getFocusableElements(container) returns all matching nodes as implemented in useFocusTrap.ts – lines 23‑33.
Initial Focus Management
When a dialog opens, accessibility best practices require moving focus to the first focusable element. The focusFirstDescendant(container) utility walks the focusable elements list and calls .focus() on the first accepted target. The hook exposes this behavior through the focusFirst return value, allowing programmatic control after state changes as shown in useFocusTrap.ts – lines 48‑58.
Wrap-Around Keyboard Navigation
The hook intercepts Tab and Shift+Tab keydown events to create a circular focus loop. If focus rests on the last element and the user presses Tab, focus moves to the first element. Conversely, Shift+Tab on the first element moves focus to the last. This "wrap-around" behavior prevents focus from escaping to the background content and is handled in the keydown listener at useFocusTrap.ts – lines 30‑43.
Escape Key Handling
For modal dialogs, the hook accepts an optional onEscape callback. When the user presses the Escape key, this callback fires, typically triggering a close action. This implementation follows the WAI-ARIA authoring practices for dialog dismissal and is located at useFocusTrap.ts – lines 12‑16.
Focus-Out Protection
A capture-phase focus listener tracks the last focused element. If keyboard navigation causes focus to leave the container (detected via isKeyboardNavigationRef), the hook redirects focus back to the first focusable descendant. If that element is the same as the current focus (indicating a Shift+Tab from the first element), it falls back to the last descendant. Mouse-initiated focus changes are ignored to support light-dismiss patterns. This logic appears in useFocusTrap.ts – lines 60‑86.
Integration with usePopover
While useFocusTrap can be used directly, Astryx recommends higher-level hooks for common patterns. The usePopover hook automatically composes useFocusTrap, attaching the trap to the popover's content wrapper and wiring the Escape key to layer.hide. This integration occurs in usePopover.tsx – lines 94‑99, where useFocusTrap is instantiated with isActive: layer.isOpen.
Implementation Examples
Basic Modal with Direct useFocusTrap
For custom dialogs that do not use the Popover component, import useFocusTrap directly and attach the containerRef to the root element. Call focusFirst when the modal opens to ensure immediate keyboard accessibility.
import {useFocusTrap} from '@astryxdesign/core';
import {useEffect} from 'react';
export function SimpleModal({open, onClose}: {open: boolean; onClose: () => void}) {
const {containerRef, focusFirst} = useFocusTrap({
isActive: open,
onEscape: onClose,
});
// Programmatically focus the first element when opening
useEffect(() => {
if (open) {
focusFirst();
}
}, [open, focusFirst]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
ref={containerRef}
style={{background: 'white', padding: 20, borderRadius: 8}}
>
<h2>Modal title</h2>
<button onClick={onClose}>Close</button>
<input placeholder="Focus stays inside…" />
</div>
);
}
This implementation guarantees that Tab cycles between the "Close" button and the input field, and pressing Escape triggers onClose.
Using useFocusTrap via usePopover
For popover-based interfaces, leverage the built-in focus trap by using usePopover. The hook automatically manages focus trapping, Escape handling, and screen-reader announcements.
import {usePopover} from '@astryxdesign/core';
import {useRef} from 'react';
import {Calendar} from '@astryxdesign/lab';
export function DatePicker() {
const inputRef = useRef<HTMLInputElement>(null);
const popover = usePopover({
onHide: () => inputRef.current?.focus(),
closeButtonLabel: 'Close calendar',
dialogLabel: 'Select a date',
});
return (
<>
<input ref={inputRef} placeholder="Pick a date…" />
<button
ref={popover.triggerRef}
onClick={popover.toggle}
{...popover.triggerProps}
>
Open calendar
</button>
{popover.render(<Calendar />)}
</>
);
}
Here, usePopover instantiates useFocusTrap internally, providing a hidden close button for screen readers and returning focus to the trigger when the popover closes.
Summary
useFocusTrapis located inpackages/core/src/hooks/useFocusTrap.tsand exports acontainerRefandfocusFirsthelper.- The hook uses
FOCUSABLE_SELECTORto identify interactive elements and implements circular Tab navigation to prevent focus from escaping containers. - Escape key handling is provided via the optional
onEscapecallback, commonly used to close modals. - For most use cases, prefer
usePopover(which composesuseFocusTrapautomatically) rather than implementing the trap manually. - Always call
focusFirstafter opening a dialog to satisfy WCAG focus management requirements.
Frequently Asked Questions
What is the useFocusTrap hook in Astryx?
useFocusTrap is a React hook in the Astryx design system that implements the WAI-ARIA dialog focus-trap pattern. It ensures keyboard users remain within a modal or popover container until they explicitly dismiss it, handling Tab navigation, Escape keys, and focus restoration.
How does useFocusTrap handle tab navigation?
The hook intercepts Tab and Shift+Tab keydown events. When focus reaches the last focusable element, pressing Tab moves focus to the first element, creating a circular loop. Conversely, Shift+Tab from the first element moves focus to the last. This prevents users from accidentally tabbing into background content.
When should I use useFocusTrap vs usePopover?
Use usePopover for standard popover, tooltip, or dropdown UIs, as it automatically composes useFocusTrap along with positioning and trigger management. Use useFocusTrap directly for custom dialog implementations or non-popover containers that require focus management, such as full-screen modals or wizard step containers.
How do I focus the first element when a dialog opens?
The hook returns a focusFirst function that you should call programmatically when the dialog opens. Wrap this call in a useEffect that triggers when the open state changes, as shown in the minimal modal example above. This ensures compliance with WCAG 2.1 Level AA focus order requirements.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →