Focus Management in React: Trap, Return and Programmatic Focus
Master focus trapping, return focus, and programmatic focus in React. Practical patterns for modals, drawers and accessible keyboard navigation that actually works.
Why Focus Management Is the Part Everyone Skips
You ship a modal. It looks great. Click the trigger, the overlay fades in, the card slides up — beautiful. Then a keyboard user opens it and their focus is still sitting on the button behind the overlay, tabbing through the nav, the sidebar, the footer. The modal might as well not exist. That's a real accessibility failure, and it's also just broken UX for anyone not using a mouse.
Focus management covers three distinct problems: where focus goes when something opens, what it does while that thing is open, and where it goes when the thing closes. Most developers solve the first one by accident (the first interactive element in the DOM gets focus eventually), ignore the second entirely, and forget the third exists. In practice, all three matter equally.
Honestly, this isn't a niche accessibility checkbox thing. Screen reader users depend on it. Keyboard-only power users depend on it. Anyone who's ever hit Tab to navigate a form quickly and watched their focus disappear into a background element knows the frustration. If your component library — whether it's something you built or something like Empire UI — doesn't handle this correctly, every overlay-type component in your app is broken for a non-trivial percentage of your users.
This article walks through each piece: programmatic focus, focus trapping for modals and drawers, and returning focus on close. All with React, all with real code.
Programmatic Focus: The Basics and the Gotchas
Moving focus programmatically means calling .focus() on a DOM node. In React you do that through refs. The simple version looks like this:
``tsx
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
dialogRef.current?.focus();
}, [isOpen]);
// In JSX:
<div ref={dialogRef} tabIndex={-1} role="dialog" aria-modal="true">
{/* ... */}
</div>
`
The tabIndex={-1} is non-negotiable. It makes the element programmatically focusable without inserting it into the natural tab order. Without it, .focus() silently does nothing on a div`.
The useEffect timing is where people get caught. React 18 renders are concurrent — by the time your effect fires, the DOM is painted, but if you're conditionally rendering the dialog (not just hiding it with CSS), you need the effect to depend on isOpen being truthy before the element even exists. That works fine. What doesn't work is trying to focus something before it's mounted. Quick aside: if you're using a portal for your modal (you should be), the ref still works because .focus() operates on the real DOM, not the React tree.
One more thing — don't focus the dialog container itself if you can avoid it. Focus the first logical interactive element inside it, or a visible heading if there are no interactive elements immediately available. The container div approach is a fallback for when you genuinely don't know what's inside. When you do know, target the primary action:
``tsx
const primaryButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen) {
// Small tick to let any animation finish before grabbing focus
const id = setTimeout(() => primaryButtonRef.current?.focus(), 50);
return () => clearTimeout(id);
}
}, [isOpen]);
``
That 50ms is a pragmatic workaround for CSS transition timing. It's a bit of a hack but it's the honest answer.
Worth noting: document.activeElement is your best friend for debugging this. Drop console.log(document.activeElement) in your effect and you'll immediately see whether focus landed where you expected.
Building a Focus Trap From Scratch
A focus trap keeps keyboard navigation inside a container while it's active. Tab wraps from the last focusable element back to the first. Shift+Tab wraps from the first back to the last. Nothing outside the trap is reachable via keyboard. This is exactly what ARIA's aria-modal="true" is supposed to communicate to screen readers, but the actual keyboard behavior is on you to implement — browsers don't enforce it automatically.
The algorithm is straightforward. Grab all focusable elements inside the container, intercept Tab and Shift+Tab keydown events, and redirect focus if you're at either boundary:
``tsx
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
function useFocusTrap(containerRef: React.RefObject<HTMLElement>, active: boolean) {
useEffect(() => {
if (!active || !containerRef.current) return;
const container = containerRef.current;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusable = Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE)
).filter(el => !el.closest('[inert]'));
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
container.addEventListener('keydown', handleKeyDown);
return () => container.removeEventListener('keydown', handleKeyDown);
}, [active, containerRef]);
}
``
A few things in that snippet that aren't obvious. The [inert] check filters out elements that are visually inside your container but programmatically hidden — think a nested panel that's collapsed. The inert attribute (broadly supported since 2023) marks entire subtrees as non-interactive, and your focus trap should respect that. If you skip this filter, users can Tab into invisible content.
That said, building this by hand has a maintenance cost. Libraries like focus-trap (the npm package) and Radix UI's Dialog primitive both implement this correctly and handle edge cases you haven't thought of yet — like dealing with Shadow DOM, iframes, and elements that become focusable after the trap initialises. Unless you're building a component library yourself, reaching for one of those is the sane call. The hand-rolled version above is worth understanding so you know what the library is doing for you.
Look, the selector string is also something you'll revisit. In 2025 the :is() pseudo-class cleaned this up a lot, and you can simplify to :is(a, button, input, select, textarea, [tabindex]):not([disabled]):not([tabindex="-1"]) in environments where you control the browser support floor.
Returning Focus When a Component Closes
This is the one everyone forgets. You open a modal, the user closes it, and focus vanishes — usually to document.body. That means the next Tab press starts from the very top of the page. For a keyboard user who was 40% of the way down a long page, that's a nightmare.
The fix is simple: save a reference to whatever had focus before the modal opened, and restore it on close.
``tsx
function useReturnFocus(isOpen: boolean) {
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement;
} else {
previousFocusRef.current?.focus();
previousFocusRef.current = null;
}
}, [isOpen]);
}
`
Drop that in your modal component alongside your focus trap hook. When isOpen goes true, you capture the trigger element. When it goes false`, you return to it. Clean.
There's one edge case worth handling: what if the trigger element was removed from the DOM while the modal was open? That happens more than you'd think — dynamic lists where the triggering item gets deleted, for instance. Check that the element is still in the document before calling .focus():
``tsx
if (
previousFocusRef.current &&
document.contains(previousFocusRef.current)
) {
previousFocusRef.current.focus();
}
``
Two extra lines, saves you a cryptic console error.
If you're building these patterns into a design system — or if you're relying on one like Empire UI where interactive components like modals and drawers ship pre-built — make sure the library's documentation explicitly states that return focus is handled. If it doesn't mention it, test it manually. Open a modal with your keyboard. Close it with Escape. Where did your focus go?
Escape Key, Overlays and the aria-modal Pattern
While you're wiring up keyboard behavior, Escape key handling belongs in the same block. The ARIA spec is explicit: any dialog, popover or menu opened with a trigger should close when the user presses Escape, and focus should return to the trigger. It's not a nice-to-have.
``tsx
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
`
Attach this at document` level, not on the dialog container, because focus might be on a child element that doesn't bubble keyboard events up to the container in all browsers.
On aria-modal="true": this attribute tells screen readers to treat the dialog as a modal context and ignore content outside it — but as of 2026, browser support for actually enforcing this varies. Safari's VoiceOver respects it. NVDA with Firefox is inconsistent. So aria-modal is necessary but not sufficient. Your JavaScript focus trap is still required alongside it.
The overlay click-to-close pattern is worth a word too. When a user clicks the backdrop, focus typically ends up on document.body because the click landed outside any focusable element. Make sure your onClose handler from the backdrop click also triggers the return-focus logic — not just the Escape key handler. Both close paths need to clean up focus the same way.
One more thing — don't put aria-modal on non-modal overlays like tooltips, dropdowns or autocomplete listboxes. It's specifically for dialogs that block interaction with the rest of the page. Misusing it breaks the screen reader experience for those components in a different way. Tooltips use role="tooltip", dropdowns use role="listbox" or role="menu" with appropriate arrow key navigation — that's a whole separate article.
Putting It Together: A Composable Modal Hook
Here's what a complete, composable focus management setup looks like for a modal. All three concerns — initial focus, trapping, return focus — in one hook:
``tsx
export function useModalFocus(isOpen: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
// Capture + restore
useEffect(() => {
if (isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement;
// Focus the container after mount
setTimeout(() => containerRef.current?.focus(), 16);
} else {
if (
previousFocusRef.current &&
document.contains(previousFocusRef.current)
) {
previousFocusRef.current.focus();
}
previousFocusRef.current = null;
}
}, [isOpen]);
// Trap
useEffect(() => {
if (!isOpen || !containerRef.current) return;
const container = containerRef.current;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusable = Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE)
).filter(el => !el.closest('[inert]'));
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus();
}
};
container.addEventListener('keydown', handleKeyDown);
return () => container.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
return containerRef;
}
`
Usage is dead simple:
`tsx
function Modal({ isOpen, onClose, children }) {
const containerRef = useModalFocus(isOpen);
if (!isOpen) return null;
return createPortal(
<div
ref={containerRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
className="modal-container"
>
{children}
</div>,
document.body
);
}
``
That setTimeout(..., 16) is one frame at 60fps — roughly 16.67ms. It's enough to let CSS transitions start before focus lands so the browser paints the element before it receives focus. Not elegant, but it's what works in the real world across Chrome, Firefox and Safari as of 2026.
If you're building a UI library or need this pattern in more than a couple of places, extract the focusable selector string into a shared constant, make the initial focus target configurable (pass a ref or a selector string), and add an option to disable the Escape handler independently of the trap. Those three configuration points cover 95% of real use cases without blowing up the API.
Worth noting: if you're using Radix UI, Headless UI, or Ark UI, they handle all of this internally. The hook above is for when you're rolling your own or auditing why an existing implementation is broken. Either way, understanding the mechanics means you can fix it instead of guessing.
Testing Focus Behavior Without Losing Your Mind
Manual keyboard testing is non-negotiable. Open your component, Tab to the trigger, press Enter or Space, Tab through all the interactive elements inside the modal, confirm they loop correctly, hit Escape, confirm focus returned to the trigger. That whole sequence takes 20 seconds and catches every common failure mode.
For automated testing, Playwright is your best option for focus assertions. You can check page.locator(':focus') after interactions. A basic test for a modal looks like this:
``ts
test('modal returns focus to trigger on close', async ({ page }) => {
await page.goto('/your-page');
const trigger = page.getByRole('button', { name: 'Open modal' });
await trigger.focus();
await trigger.press('Enter');
// Dialog should now be focused (or first element inside it)
await expect(page.getByRole('dialog')).toBeFocused();
await page.keyboard.press('Escape');
// Trigger should have focus back
await expect(trigger).toBeFocused();
});
``
That test catches regressions automatically and documents the intended behavior for the next developer.
Screen reader testing is harder to automate but you should do it manually at least once per component type. NVDA + Firefox on Windows, VoiceOver + Safari on macOS. They behave differently, and both of them will surface problems that automated tests miss — like the screen reader announcing the wrong role, or aria-modal not being respected correctly in one browser.
Honestly, accessibility-conscious component libraries like the ones you'd find in the Empire UI library handle this for you in their interactive components — but testing your own integration wiring still matters. A correctly implemented modal can still break if you conditionally render it in a way that defeats the return-focus timing.
FAQ
They solve different things. aria-modal="true" tells screen readers to ignore content outside the dialog. A focus trap keeps keyboard Tab navigation inside the dialog in the browser. You need both — aria-modal alone doesn't enforce keyboard containment.
Use a library unless you're building a component library. The focus-trap npm package and Radix UI primitives handle edge cases like Shadow DOM, dynamic content changes, and cross-browser quirks that a hand-rolled trap will miss initially.
Usually a timing issue — you're calling it before the element is mounted, or a CSS transition hasn't completed so the browser suppresses it. Wrap it in a setTimeout with 16–50ms or call it inside a useLayoutEffect instead of useEffect.
Yes. The focus trap only manages Tab navigation. Escape key behavior — closing the dialog and returning focus — is a separate event listener on document. Wire them up independently so either path correctly restores focus.