Focus management in modals: patterns, pitfalls and fixes
When a modal appears on screen, keyboard users and screen reader users expect focus to move with them. Most developers get this wrong, or skip it entirely. Proper focus management in modals isn't a nice-to-have—it's the difference between an accessible experience and a broken one.
Why focus management matters
Think about how you use a modal. You click a button, a dialog appears, and you expect the first focusable element to be ready for keyboard input. But if focus stays on the button behind the modal, several problems cascade:
- Keyboard users don't know where they are.
- Screen reader users hear content from the hidden page instead of the modal.
- Tab key presses cycle through invisible elements.
- Form submissions happen on the wrong element.
Focus is invisible to sighted mouse users, which is why it gets overlooked. But for anyone navigating with a keyboard or assistive technology, focus location is everything. It's the anchor point for where they are in the interface.
Move focus into the modal on open
When a modal opens, focus must move from wherever it was (usually the trigger button) into the modal itself. The question is: where inside the modal?
The safest default is the first focusable element inside the modal—often a close button or the first form field. Some designs put focus on a heading, which works too, though it's less common in production applications.
Here's a React component that handles this:
import { useRef, useEffect, useState } from 'react';
export function Modal({ isOpen, onClose, title, children }) {
const modalRef = useRef(null);
const firstFocusableRef = useRef(null);
useEffect(() => {
if (isOpen && firstFocusableRef.current) {
// Move focus into the modal when it opens
firstFocusableRef.current.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div>
<h2 id="title">{title}</h2>
<div>
{children}
Close
</div>
</div>
);
}The key line is firstFocusableRef.current.focus() inside the useEffect hook. This runs after the modal renders and moves focus explicitly into it.
A common mistake here is focusing the modal container itself instead of a focusable element. Divs and containers aren't keyboard-focusable by default. You need to focus an actual button, input, link, or an element with tabIndex="0".
Trap focus while it is open
Focus trapping means that when a keyboard user presses Tab at the last focusable element in a modal, focus wraps back to the first. This prevents focus from leaking into the background page, which would be confusing and dangerous if the user accidentally submits a form they can't see.
Implementing focus trap manually requires tracking all focusable elements and intercepting keyboard events:
import { useRef, useEffect, useState } from 'react';
export function ModalWithFocusTrap({ isOpen, onClose, title, children }) {
const modalRef = useRef(null);
const firstFocusableRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
// Move focus into the modal
if (firstFocusableRef.current) {
firstFocusableRef.current.focus();
}
const handleKeyDown = (e) => {
if (e.key !== 'Tab') return;
// Get all focusable elements within the modal
const focusableElements = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const activeElement = document.activeElement;
// Trap focus: wrap around when reaching boundaries
if (e.shiftKey && activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
if (!isOpen) return null;
return (
<div>
<h2 id="title-x">{title}</h2>
<div>
{children}
Close
</div>
</div>
);
}This pattern queries all focusable elements, detects when Tab or Shift+Tab reaches a boundary, and wraps focus accordingly. The selector includes common interactive elements plus any element with a positive tabindex.
Many teams use a library for this instead of rolling it yourself. focus-trap on npm is battle-tested and handles edge cases like dynamically added elements. For most production applications, that's the right call.
Return focus to the trigger on close
When a modal closes, focus should return to the element that opened it. This restores the user's position in the page flow and prevents focus from jumping to an unexpected location.
To do this, store a reference to the trigger element before opening the modal, then restore focus when closing:
import { useRef, useState } from 'react';
export function ModalExample() {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef(null);
const handleOpen = () => {
setIsOpen(true);
};
const handleClose = () => {
setIsOpen(false);
// Restore focus to the trigger after the modal closes
if (triggerRef.current) {
triggerRef.current.focus();
}
};
return (
<>
Open Settings
{isOpen && (
{
e.preventDefault();
handleClose();
}}>
Email:
Save
)}
);
}The pattern here is simple: save the trigger element in a ref, and call focus() on it when the modal closes. This is especially important for modals opened from a button press, where the user expects to stay in roughly the same place after the interaction completes.
The native dialog element helps a lot
HTML has a <dialog> element that handles much of this for you. When you call showModal(), it automatically manages focus and adds a backdrop. Modern browsers handle focus trapping natively, and screen readers understand the structure without additional ARIA attributes.
import { useRef, useEffect, useState } from 'react';
export function NativeDialogExample() {
const dialogRef = useRef(null);
const triggerRef = useRef(null);
const [isOpen, setIsOpen] = useState(false);
const handleOpen = () => {
setIsOpen(true);
// Use the native showModal() method
if (dialogRef.current) {
dialogRef.current.showModal();
}
};
const handleClose = () => {
setIsOpen(false);
if (dialogRef.current) {
dialogRef.current.close();
}
// Focus management is handled automatically, but we can still do it explicitly
if (triggerRef.current) {
triggerRef.current.focus();
}
};
return (
<>
Open Settings
<h2 id="settings">Settings</h2>
Email:
Save
);
}Using <dialog> is simpler and more semantic. The browser handles inert backgrounds, focus management, and stack management. Browser support is solid (98%+ on modern browsers), though IE 11 and some older mobile browsers still lack it.
When using <dialog>, the first focusable element in the dialog still needs to be identified explicitly if it's not a button, but the browser will do most of the work for you.
Common mistakes
Forgetting to move focus into the modal on open. This is the most common mistake. The modal renders, but focus stays on the trigger button. For keyboard and screen reader users, nothing has changed.
Focusing the wrong element. Developers sometimes focus the modal container instead of a focusable element. Divs, sections, and other non-interactive elements can't receive focus by default. Always focus a button, input, or an element with tabIndex="0".
Not trapping focus. If a user presses Tab repeatedly in the modal, focus leaks into the background page. They can accidentally interact with hidden elements or feel lost.
Ignoring focus return on close. When the modal closes, focus jumps to the top of the page or stays wherever it was. The user's position in the flow is lost.
Using custom implementations instead of <dialog> when possible. Rolling your own modal is tempting, but <dialog> does the heavy lifting. If you need backwards compatibility, use a library like focus-trap rather than reinventing the wheel.
Key takeaways
- Move focus into the modal on open by calling
focus()on the first focusable element. - Trap focus while the modal is open to prevent keyboard users from interacting with the background page.
- Return focus to the trigger element when the modal closes to restore the user's position.
- Use the native
<dialog>element when possible—it handles focus management and accessibility automatically. - Test with keyboard navigation and a screen reader to catch focus management bugs before production.
- Common mistakes include forgetting focus moves, focusing non-focusable elements, and not restoring focus on close.






Comments
Loading comments…