Focus Trapping and the Inert Attribute
The Focus Escape Problem
Open a modal on most websites, press Tab a few times, and watch your focus disappear behind the overlay into the page content you can't even see. You're tabbing through invisible links, clicking buttons you didn't know were there, and the screen reader is reading out content that's supposed to be hidden.
This isn't a minor annoyance. For keyboard and screen reader users, it's a complete breakdown of the interface. They can't tell where they are, they can't get back to the modal, and they might accidentally trigger actions on the page behind it.
Think of a modal like a room with a locked door. For mouse users, the overlay acts as the lock — they can't click through it. But for keyboard users, there's no lock at all. Tab is a teleporter that goes right through walls. Focus trapping is installing an actual lock. The inert attribute goes further — it doesn't just lock the door to the other rooms, it makes those rooms stop existing entirely.
When You Need Focus Trapping
Focus trapping is required whenever a piece of UI demands the user's full attention and interaction before they can return to the main page:
- Modal dialogs — confirmation prompts, forms, settings panels
- Drawer/side panels — navigation menus that overlay content
- Lightboxes — image galleries, video players
- Alert dialogs — critical warnings that require acknowledgment
- Custom dropdowns — complex select menus with nested options
The common thread: these all create a temporary context that should be the only thing the user interacts with until they explicitly dismiss it.
- 1Focus must move into the trap when it opens — never leave focus on the trigger button behind the overlay
- 2Tab and Shift+Tab must cycle within the trap — focus wraps from last to first and first to last
- 3Escape must close the trap and return focus to the element that opened it
- 4No focusable element outside the trap should be reachable via keyboard while the trap is active
- 5Screen readers must not be able to navigate to content outside the trap using virtual cursor
Building a Focus Trap From Scratch
The core mechanics of a focus trap involve three things: finding all focusable elements, intercepting Tab key presses, and cycling focus between the first and last elements.
Step 1: Find All Focusable Elements
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'details > summary',
'audio[controls]',
'video[controls]',
'[contenteditable]',
].join(', ')
function getFocusableElements(container: HTMLElement): HTMLElement[] {
const elements = Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
)
return elements.filter(
(el) => !el.hasAttribute('disabled') && el.offsetParent !== null
)
}
That offsetParent !== null check filters out elements that are hidden via display: none or visibility: hidden — they're in the DOM but not visible, so they shouldn't receive focus.
Step 2: Intercept Tab and Cycle Focus
function trapFocus(event: KeyboardEvent, container: HTMLElement) {
if (event.key !== 'Tab') return
const focusable = getFocusableElements(container)
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
Step 3: Activate and Deactivate
function activateTrap(container: HTMLElement) {
const focusable = getFocusableElements(container)
if (focusable.length > 0) {
focusable[0].focus()
}
const handler = (e: KeyboardEvent) => trapFocus(e, container)
container.addEventListener('keydown', handler)
return () => container.removeEventListener('keydown', handler)
}
The Sentinel Approach
An alternative to intercepting Tab is placing invisible sentinel elements at the start and end of the trap. When they receive focus, they redirect it:
<div role="dialog" aria-modal="true" aria-label="Settings">
<span tabindex="0" data-sentinel="start"></span>
<!-- Modal content with focusable elements -->
<span tabindex="0" data-sentinel="end"></span>
</div>
function setupSentinels(container: HTMLElement) {
const startSentinel = container.querySelector('[data-sentinel="start"]')
const endSentinel = container.querySelector('[data-sentinel="end"]')
startSentinel?.addEventListener('focus', () => {
const focusable = getFocusableElements(container)
focusable[focusable.length - 1]?.focus()
})
endSentinel?.addEventListener('focus', () => {
const focusable = getFocusableElements(container)
focusable[0]?.focus()
})
}
When a user tabs past the last real focusable element, the end sentinel catches focus and redirects it to the first element. Shift+Tab past the first element hits the start sentinel and redirects to the last. No keydown interception needed.
Keydown interception vs sentinel approach
The keydown approach is more common in libraries like focus-trap, but sentinels have one advantage: they work even if focus enters the container from outside (via a screen reader's virtual cursor). Keydown only fires when the user physically presses Tab, but screen readers can move focus independently using arrow keys and other navigation commands. Sentinels act as guardrails no matter how focus arrives. The downside: sentinels are real DOM elements that screen readers might announce, so you need aria-hidden="true" on them to keep the experience clean.
The Problem With DIY Focus Traps
Even a well-built focus trap has gaps:
- Dynamic content — if new focusable elements are added to the modal (loading a form, expanding an accordion), you need to recalculate the focusable elements list
- Screen reader virtual cursor — the Tab-interception approach doesn't stop screen readers from reading content outside the modal
- Touch devices — focus trapping doesn't help if the user can swipe to navigate outside the trap
- Multiple traps — stacking modals (a confirmation dialog inside a settings modal) requires a trap stack
- Background scroll — keyboard users can still scroll the page behind the modal without focus leaving the trap
These problems all point to the same root cause: a focus trap is a band-aid on the wrong layer. Instead of keeping focus in, you should be making everything outside unreachable. That's exactly what inert does.
The inert Attribute
The inert attribute is a single HTML attribute that does what 50 lines of focus trapping JavaScript tries to do:
<body>
<main inert>
<!-- Everything here is completely unreachable -->
</main>
<div role="dialog" aria-modal="true">
<!-- This is the only interactive content -->
</div>
</body>
When you add inert to an element, three things happen:
- Removed from tab order — the element and all its descendants are unfocusable. Tab skips right past them.
- Removed from accessibility tree — screen readers can't navigate to, read, or interact with any content inside. The virtual cursor treats it as if it doesn't exist.
- Click events disabled — pointer events on inert elements are ignored. No accidental clicks through the overlay.
That's the entire API. One boolean attribute, three devastating effects.
If a focus trap is a fence around your modal, inert is demolishing every other building on the block. There's nothing to escape to. The modal isn't keeping users in — there's simply nowhere else to go.
Browser Support
The inert attribute is supported in all modern browsers since early 2023:
- Chrome 102+ (May 2022)
- Firefox 112+ (April 2023)
- Safari 15.5+ (May 2022)
- Edge 102+ (May 2022)
For older browser support, the wicg-inert polyfill exists, but given the current support landscape, most production applications no longer need it.
How inert Interacts With ARIA
When inert is applied:
aria-hidden="true"is implied — you don't need to add it separatelytabindexvalues are ignored — eventabindex="0"elements become unfocusablearia-liveregions inside inert elements stop announcing updates- Any element with
role="dialog"inside an inert subtree is invisible to assistive technology
This means inert is strictly more powerful than manually setting aria-hidden="true" + tabindex="-1" on every element. It handles the accessibility tree, the tab order, and pointer events in one shot.
The dialog Element: Focus Trapping Built In
The HTML dialog element, when opened with its showModal() method, provides built-in focus trapping without any JavaScript focus management:
<dialog id="settings-dialog">
<h2>Settings</h2>
<form method="dialog">
<label>
Username
<input type="text" name="username" />
</label>
<button type="submit">Save</button>
<button type="button" onclick="this.closest('dialog').close()">
Cancel
</button>
</form>
</dialog>
<button onclick="document.getElementById('settings-dialog').showModal()">
Open Settings
</button>
When showModal() is called:
- Focus moves inside the dialog — the browser focuses the first focusable element (or the dialog itself if
autofocusis set on a child) - A backdrop is rendered — the
::backdroppseudo-element covers the page behind the dialog - Focus is trapped — Tab and Shift+Tab cycle within the dialog
- Escape closes it — the browser fires a
cancelevent, then closes the dialog - Background content becomes inert — the browser implicitly applies inert-like behavior to everything outside the dialog. Screen readers can't navigate outside. This is the key differentiator from
.show().
showModal() vs show()
This distinction matters:
dialog.show() // Opens the dialog, no backdrop, no focus trap, no inert background
dialog.showModal() // Opens as modal: backdrop, focus trap, inert background, Escape to close
.show() opens the dialog as a non-modal — just a visible element on the page. No accessibility magic. No focus trapping. No backdrop. It's essentially display: block with extra steps.
.showModal() is where the real power is. It activates the browser's built-in modal behavior, which handles every focus management concern we've discussed so far — and it does it better than any JavaScript library because it operates at the browser level, not the DOM level.
Why showModal() traps the screen reader cursor
When you call showModal(), the browser marks the dialog as a top-layer element. The top layer is a browser-internal concept (not CSS z-index) that places elements above everything else in the document. But more importantly for accessibility, the browser applies what the spec calls an "inert subtrees" algorithm to everything outside the top-layer element. This is why screen readers can't escape a modal dialog opened via showModal() — the browser itself is telling the accessibility tree that everything outside doesn't exist. No JavaScript polyfill can match this level of integration.
The method=dialog Form Trick
When a form inside a dialog has method="dialog", submitting the form automatically closes the dialog and sets the dialog's returnValue to the value of the submit button:
<dialog id="confirm">
<p>Are you sure?</p>
<form method="dialog">
<button value="cancel">Cancel</button>
<button value="confirm">Confirm</button>
</form>
</dialog>
const dialog = document.getElementById('confirm') as HTMLDialogElement
dialog.showModal()
dialog.addEventListener('close', () => {
if (dialog.returnValue === 'confirm') {
deleteAccount()
}
})
No event.preventDefault(), no manual close calls, no tracking which button was pressed. The platform handles it.
The Complete Pattern: dialog + inert
Even though showModal() makes background content inert internally, there are scenarios where you need explicit inert alongside the dialog element:
Stacked Modals
When a modal opens another modal, the first modal should become inert:
function openNestedModal(
innerDialog: HTMLDialogElement,
outerDialog: HTMLDialogElement
) {
outerDialog.inert = true
innerDialog.showModal()
innerDialog.addEventListener('close', () => {
outerDialog.inert = false
outerDialog.querySelector<HTMLElement>('[autofocus]')?.focus()
}, { once: true })
}
Non-dialog Modals
If you're building a custom drawer, sidebar, or bottom sheet that doesn't use the dialog element, you need inert on the background:
function openDrawer(drawer: HTMLElement) {
const main = document.querySelector('main')
const header = document.querySelector('header')
drawer.hidden = false
drawer.setAttribute('role', 'dialog')
drawer.setAttribute('aria-modal', 'true')
main?.setAttribute('inert', '')
header?.setAttribute('inert', '')
const firstFocusable = drawer.querySelector<HTMLElement>(
'a[href], button, input, [tabindex]:not([tabindex="-1"])'
)
firstFocusable?.focus()
}
function closeDrawer(
drawer: HTMLElement,
triggerButton: HTMLElement
) {
drawer.hidden = true
drawer.removeAttribute('role')
drawer.removeAttribute('aria-modal')
document.querySelector('main')?.removeAttribute('inert')
document.querySelector('header')?.removeAttribute('inert')
triggerButton.focus()
}
Notice the pattern: when the drawer opens, we mark everything else as inert. When it closes, we remove inert and restore focus to the trigger button. This is the complete accessible pattern — no focus trap library needed.
Focus Restoration
One detail that's easy to forget: when a modal closes, focus must return to the element that opened it. Without this, focus drops to the top of the page (or worse, to document.body), leaving the user stranded.
function createModalManager() {
let triggerStack: HTMLElement[] = []
return {
open(dialog: HTMLDialogElement) {
const trigger = document.activeElement as HTMLElement
triggerStack.push(trigger)
dialog.showModal()
},
close(dialog: HTMLDialogElement) {
dialog.close()
const trigger = triggerStack.pop()
trigger?.focus()
},
}
}
The dialog element with showModal() handles focus restoration automatically in most browsers — when the dialog closes, focus returns to the element that was focused before showModal() was called. But if you're building custom modal-like components, you need to manage this yourself.
React Pattern: useInert Hook
In React applications, managing inert is cleanest as a custom hook:
import { useEffect, useRef } from 'react'
function useInert(isActive: boolean, containerRef: React.RefObject<HTMLElement | null>) {
const previouslyFocused = useRef<HTMLElement | null>(null)
useEffect(() => {
if (!isActive || !containerRef.current) return
previouslyFocused.current = document.activeElement as HTMLElement
const siblings = Array.from(
document.body.children
).filter(
(child): child is HTMLElement =>
child instanceof HTMLElement &&
child !== containerRef.current &&
!child.closest('[role="dialog"]')
)
siblings.forEach((el) => el.setAttribute('inert', ''))
return () => {
siblings.forEach((el) => el.removeAttribute('inert'))
previouslyFocused.current?.focus()
}
}, [isActive, containerRef])
}
Usage:
function Modal({ isOpen, onClose, children }: {
isOpen: boolean
onClose: () => void
children: React.ReactNode
}) {
const dialogRef = useRef<HTMLDialogElement>(null)
useInert(isOpen, dialogRef)
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
if (isOpen) {
dialog.showModal()
} else {
dialog.close()
}
}, [isOpen])
return (
<dialog ref={dialogRef} onClose={onClose}>
{children}
</dialog>
)
}
This hook does three things: marks all sibling elements as inert when the modal is active, removes inert when it deactivates, and restores focus to the previously focused element on cleanup.
Testing Focus Traps
Testing focus management is non-negotiable. Here's what to verify:
- Tab cycles within the trap — focus goes from last focusable to first, and Shift+Tab goes from first to last
- Screen reader confinement — use VoiceOver (macOS) or NVDA (Windows) to verify the virtual cursor can't escape
- Focus on open — opening the modal moves focus into it immediately
- Focus on close — closing the modal returns focus to the trigger element
- Dynamic content — adding or removing focusable elements inside the trap doesn't break cycling
- Escape key — pressing Escape closes the modal and restores focus
// Playwright test example
test('modal traps focus', async ({ page }) => {
await page.click('[data-testid="open-modal"]')
const modal = page.locator('[role="dialog"]')
await expect(modal).toBeFocused()
const focusableCount = await modal
.locator('button, input, a[href], [tabindex]:not([tabindex="-1"])')
.count()
for (let i = 0; i < focusableCount + 2; i++) {
await page.keyboard.press('Tab')
}
const activeElement = await page.evaluate(() =>
document.activeElement?.closest('[role="dialog"]') !== null
)
expect(activeElement).toBe(true)
})
| What developers do | What they should do |
|---|---|
| Using only aria-hidden=true on background content to trap screen reader focus aria-hidden only affects the accessibility tree. Users can still Tab to and click on elements with aria-hidden=true. inert removes elements from all three interaction channels. | Use the inert attribute, which handles tab order, accessibility tree, AND pointer events |
| Forgetting to restore focus to the trigger element when the modal closes Without focus restoration, closing a modal drops focus to document.body, leaving keyboard users stranded with no context of where they were. | Save a reference to document.activeElement before opening, restore it on close |
| Using dialog.show() instead of dialog.showModal() for modal dialogs show() provides zero modal behavior — no focus trap, no backdrop, no inert background, no Escape handling. It is just visibility toggling. | Always use showModal() for dialogs that require user attention before continuing |
| Adding inert to the dialog element itself instead of the background content inert makes elements and their entire subtree unfocusable and invisible to assistive technology. Adding it to the dialog would make the dialog itself unreachable. | Add inert to content OUTSIDE the dialog, never to the dialog |
| Only trapping Tab key without handling the screen reader virtual cursor Tab-only traps leave screen readers free to navigate the entire page. The virtual cursor operates independently of keyboard focus and requires accessibility tree changes (inert or aria-modal) to confine it. | Use aria-modal=true with role=dialog, or use inert on background, or use the native dialog element with showModal() |
Decision Tree: Which Approach to Use
For most cases, the native dialog element with showModal() is the right answer. Here's when to consider alternatives:
Use the native dialog element with showModal() when:
- Building modal dialogs, confirmation prompts, or alert dialogs
- You want zero JavaScript focus management code
- You need proper screen reader confinement out of the box
Use inert on background content when:
- Building custom overlays (drawers, sidebars, bottom sheets) that don't map cleanly to
dialog - Stacking multiple modal layers
- You need fine-grained control over which parts of the page become inert
Use a focus-trap library when:
- Supporting older browsers that lack
inertordialogsupport - You have complex dynamic content inside the trap that changes frequently
- Your framework's dialog primitive doesn't support
showModal()
The ideal modern approach: use dialog + showModal() for actual modals, and use inert directly for everything else. Zero library dependencies, maximum browser-level integration.