Focus Trap
Constrain keyboard focus within modal dialogs and overlays to prevent focus from escaping to background content.
In this article
Focus Trap
Problem
Mouse users see the dimmed overlay behind a modal and understand they should interact with the modal content, but keyboard users press Tab and find themselves activating buttons beneath the overlay. Keyboard focus moves past the overlay even though the modal visually blocks the background.
I’ve watched users Tab around a modal, past the close button, into the page footer, through the entire navigation menu, and all the way back around before finally landing on the modal content. It’s as if the modal doesn’t exist for keyboard navigation.
Screen readers make this worse by announcing background content to users who can’t see the overlay, creating a mismatch between what users see and what assistive technology allows.
Solution
A focus trap intercepts Tab and Shift+Tab keypresses. When focus is on the last focusable element and the user presses Tab, focus moves back to the first element, and when focus is on the first element and the user presses Shift+Tab, focus moves to the last. Focus then cycles through the modal and never reaches the background content.
The implementation finds all focusable elements inside your modal (buttons, links, form inputs, and elements with tabindex="0"), tracks which are first and last in focus order, and listens for Tab keypresses to redirect focus when needed.
aria-modal="true" tells screen readers the background is inert, and adding the inert attribute to the main content behind the modal removes that content from keyboard and screen reader access entirely. A native <dialog> opened with showModal() makes the rest of the page inert by itself, so it doesn’t need a hand-written trap.
The modal also needs an obvious exit: the Escape key closes it, and there’s a visible close button.
Example
Basic Focus Trap Implementation
function Modal({ children, onClose }) {
const modalRef = useRef();
useEffect(() => {
const modal = modalRef.current;
if (!modal) return;
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const trapFocus = (e) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstElement) {
lastElement.focus();
e.preventDefault();
} else if (!e.shiftKey && document.activeElement === lastElement) {
firstElement.focus();
e.preventDefault();
}
}
if (e.key === 'Escape') onClose();
};
modal.addEventListener('keydown', trapFocus);
firstElement?.focus();
return () => modal.removeEventListener('keydown', trapFocus);
}, [onClose]);
return (
<div ref={modalRef} role="dialog" aria-modal="true">
{children}
</div>
);
} <template>
<div v-if="isOpen" ref="modalRef" role="dialog" aria-modal="true" @keydown="handleKeydown">
<h2 ref="firstFocusable" tabindex="-1">Modal Title</h2>
<slot />
<button @click="$emit('close')">Close</button>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
const props = defineProps(['isOpen']);
const emit = defineEmits(['close']);
const modalRef = ref(null);
const firstFocusable = ref(null);
const handleKeydown = (e) => {
if (e.key === 'Escape') { emit('close'); return; }
if (e.key !== 'Tab') return;
const focusable = modalRef.value.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0], last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
last.focus(); e.preventDefault();
} else if (!e.shiftKey && document.activeElement === last) {
first.focus(); e.preventDefault();
}
};
// flush: 'post' waits for v-if to render the dialog before focusing
watch(() => props.isOpen, (isOpen) => { if (isOpen) firstFocusable.value?.focus(); }, { flush: 'post' });
</script> class FocusTrapDialog extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.render();
this.setupFocusTrap();
}
setupFocusTrap() {
const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
this.addEventListener('keydown', (e) => {
const focusable = this.shadowRoot.querySelectorAll(selector);
const first = focusable[0], last = focusable[focusable.length - 1];
// document.activeElement only reports the host element, so check the shadow root
const active = this.shadowRoot.activeElement;
if (e.key === 'Tab') {
if (e.shiftKey && active === first) {
last.focus(); e.preventDefault();
} else if (!e.shiftKey && active === last) {
first.focus(); e.preventDefault();
}
}
if (e.key === 'Escape') this.close();
});
this.shadowRoot.querySelector(selector)?.focus();
}
close() { this.dispatchEvent(new CustomEvent('close')); }
render() {
this.shadowRoot.innerHTML = `
<div role="dialog" aria-modal="true">
<h2>Dialog Title</h2>
<slot></slot>
<button id="close-btn">Close</button>
</div>
`;
this.shadowRoot.getElementById('close-btn').addEventListener('click', () => this.close());
}
}
customElements.define('focus-trap-dialog', FocusTrapDialog); Focus Trap with Background Inert
Using the inert attribute disables the background content entirely, preventing both focus and screen reader access. The modal renders through a portal outside #root, so it isn’t made inert along with the background:
import { createPortal } from 'react-dom';
import { FocusTrap } from 'focus-trap-react';
function Modal({ isOpen, onClose, children }) {
const previousFocusRef = useRef();
useEffect(() => {
const mainContent = document.querySelector('#root');
if (isOpen) {
previousFocusRef.current = document.activeElement;
mainContent?.setAttribute('inert', '');
mainContent?.setAttribute('aria-hidden', 'true');
} else {
mainContent?.removeAttribute('inert');
mainContent?.removeAttribute('aria-hidden');
previousFocusRef.current?.focus();
}
return () => {
mainContent?.removeAttribute('inert');
mainContent?.removeAttribute('aria-hidden');
};
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<FocusTrap focusTrapOptions={{ escapeDeactivates: false }}>
<div role="dialog" aria-modal="true" onKeyDown={(e) => e.key === 'Escape' && onClose()}>
<h2>Modal Title</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</FocusTrap>,
document.body
);
}
Using focus-trap Library
The focus-trap-react library handles edge cases like dynamic content and nested traps automatically.
import { FocusTrap } from 'focus-trap-react';
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return (
<FocusTrap focusTrapOptions={{
initialFocus: '#modal-title',
escapeDeactivates: false, // Escape closes the modal below, which unmounts the trap
returnFocusOnDeactivate: true
}}>
<div role="dialog" aria-modal="true" onKeyDown={(e) => e.key === 'Escape' && onClose()}>
<h2 id="modal-title" tabIndex={-1}>Modal Title</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</FocusTrap>
);
}
For nested modals like confirmation dialogs inside settings panels, setting active={false} on the parent trap when the child opens passes focus control to the child’s trap.
Benefits
- Keyboard behavior matches visual behavior. If content looks unreachable behind an overlay, it behaves as unreachable for keyboard navigation too.
- Users can’t accidentally trigger actions on hidden background content, so they don’t press buttons hidden behind the overlay.
- The WAI-ARIA Authoring Practices dialog pattern keeps focus inside the modal, so this is the expected behavior for accessible overlays.
- Users stay inside the modal instead of getting lost in background content they can’t see.
Tradeoffs
- Tracking focusable elements is complex. You need to handle disabled elements,
tabindex="-1", hidden elements, and collapsed containers. Dynamic content breaks traps when elements appear or disappear, so the list of focusable elements needs re-querying when content changes. - A focus trap without an obvious exit frustrates users, so Escape key handling and a visible close button both need to be there.
- Nested modals require careful coordination of which trap is active and which should be suspended when child dialogs open.
- Screen reader virtual cursors can sometimes escape the trap.
aria-modal="true"signals that the background is inactive, but how that’s handled varies by browser. - The
inertattribute disables background content properly and is now supported in all major browsers, which makes it the preferred approach for new implementations. - Libraries like
focus-traphandle edge cases but add to your bundle; custom implementations require handling all edge cases yourself. - Automated tests can’t check whether the experience feels natural to users, so testing a focus trap properly needs real assistive technology.
Summary
Focus traps constrain keyboard focus within a container, preventing users from tabbing out of modals and overlays. When a modal opens, focus stays inside it, and when it closes, focus returns to the element that opened it. This keeps keyboard and screen reader users oriented and prevents accidental interactions with obscured background content.
See also
- Focus Management — Accessibility
- Keyboard Navigation — Accessibility
- ARIA Roles and Attributes — Accessibility
- Focus Trap Pattern
- focus-trap Library - GitHub
- Modal Dialog Accessibility - W3C