Modals are one of the most common sources of accessibility failures in modern web applications. They interrupt the user's flow by design — that is their job. But the same interruption that works for a mouse user who can click the backdrop to dismiss becomes a serious problem for keyboard and assistive technology users who may lose focus context, get stuck behind an invisible overlay, or continue interacting with background content they cannot see.
In production, broken modal behavior is not just bad UX. It creates audit failures, real compliance risk, and avoidable release issues. If your product serves users in regulated markets — including the EU, where accessibility requirements are increasingly codified — correct interaction patterns matter beyond user experience alone.
The good news: the rules for accessible modals are well-defined and consistent. Once you understand the pattern, it applies to every modal you build.
TL;DR — the 4 focus rules for accessible modals
- Move focus into the modal when it opens.
- Trap focus inside so Tab and Shift+Tab cycle only within the dialog.
- Support Escape to close where appropriate for dismissible dialogs.
- Return focus to the trigger when the modal closes.
Native <dialog> vs custom React modals
The native HTML <dialog> element now has broad support across modern browsers. Calling showModal() gives you genuinely useful behavior out of the box: the dialog moves to the top layer (above all other content), the browser marks background content as inert, and the ::backdrop pseudo-element provides a built-in overlay.
That said, React teams still reach for custom implementations in practice. The reasons are usually architectural, not ideological:
- Imperative vs declarative.
showModal()andclose()are imperative DOM methods. Integrating them cleanly with React's state-driven rendering requires refs and effects that feel awkward compared to a declarativeisOpenprop. - Animation and orchestration. Animating enter/exit transitions on native
<dialog>is possible but harder to coordinate with React's rendering lifecycle. - Multi-step flows and nesting. Complex wizard-style modals, confirmation sub-dialogs, and stacked modal patterns require control that the native element does not provide.
- Design system consistency. Teams with an existing component library need modals that match their styling, composition, and API conventions.
Which approach should you use?
- Use native <dialog> for simpler modal needs where platform behavior fits and animation requirements are minimal.
- Use a custom portal-based modal for complex, animated, or highly controlled UI patterns that need full lifecycle integration.
- Either way, you still need correct focus placement, focus trapping, Escape handling, and focus restoration. The native element helps with some of these, but does not guarantee all of them.
The 4 pillars of modal focus management
Every accessible modal — whether built on native <dialog>or a custom implementation — must satisfy these four requirements.
Pillar 1: Initial focus
When a modal opens, focus must move into it. Leaving focus on a background element means screen reader users hear nothing about the dialog, and keyboard users start tabbing through content they cannot see.
Where to place initial focus depends on context:
- First interactive element— the right default for most forms and action dialogs. The user can immediately start interacting.
- Dialog container or heading (with
tabIndex={-1}) — better for modals with important context the user should read first, especially confirmation dialogs or destructive-action prompts. This lets the screen reader announce the heading before the user tabs to a button.
// Focus the dialog heading on open
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
if (isOpen && headingRef.current) {
headingRef.current.focus();
}
}, [isOpen]);
// In JSX:
<h2 ref={headingRef} tabIndex={-1} id="dialog-title">
Confirm deletion
</h2>Pillar 2: Focus trap
While the modal is open, pressing Tab and Shift+Tab must cycle focus only through interactive elements inside the dialog. If focus escapes to the background, the user can interact with controls they cannot see, which breaks both usability and WCAG compliance.
To build a focus trap you need to:
- Query all focusable elements inside the dialog container (inputs, buttons, links, textareas, selects, and elements with
tabIndex >= 0). - Filter out elements that are disabled, hidden, or have
display: none. - On
Tabat the last element, wrap focus to the first. OnShift+Tabat the first element, wrap to the last.
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"]):not([disabled])',
].join(', ');
function getFocusableElements(container: HTMLElement) {
return Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
).filter(
(el) => !el.hasAttribute('aria-hidden') && el.offsetParent !== null
);
}Pillar 3: Escape hatch
For dismissible dialogs, pressing Escape should close the modal. This is a standard expectation for keyboard users and is consistent with both WAI-ARIA authoring practices and native <dialog> behavior.
Some flows require nuance: a multi-step wizard might need confirmation before discarding progress, and a destructive confirmation dialog should not dismiss silently. But for the vast majority of modals — settings panels, informational overlays, forms — Escape-to-close is expected and should be implemented.
Pillar 4: Focus restoration
When the modal closes, focus must return to the element that opened it. This preserves the user's place in the page and avoids disorientation. Without restoration, focus can jump to the top of the document or land on an arbitrary element.
The implementation pattern is straightforward:
- Capture
document.activeElementbefore the modal opens. - Store it in a ref.
- Call
.focus()on that stored element when the modal closes or unmounts.
WAI-ARIA and semantic requirements
A modal cannot be a styled <div> with a backdrop. Assistive technology needs semantic information to announce the dialog correctly and convey its boundaries to the user.
| Attribute | Purpose | When to use |
|---|---|---|
| role="dialog" | Identifies the element as a dialog window. | All standard modals (forms, settings, info overlays). |
| role="alertdialog" | Signals an urgent dialog requiring immediate response. | Destructive confirmations, critical warnings. |
| aria-modal="true" | Tells assistive tech that content behind the dialog is inert. | Always, on every modal dialog container. |
| aria-labelledby | Points to the element providing the dialog's accessible name (usually the heading). | Always. Every dialog needs an accessible name. |
| aria-describedby | Points to supplementary description text (optional). | When a brief description adds clarity. Omit for dense content. |
ARIA describes, it does not behave
aria-modal="true" tells screen readers that background content should be treated as inert, but it does notcreate a focus trap, move focus, or prevent keyboard interaction with background elements. You must implement those behaviors in code. Semantics and behavior are both required — neither is sufficient alone.For background inertness, modern browsers support the inert HTML attribute, which disables all interaction (focus, click, selection) on an element and its children. Apply it to your app root container while the modal is open for a robust solution:
// When modal opens
document.getElementById('app-root')?.setAttribute('inert', '');
// When modal closes
document.getElementById('app-root')?.removeAttribute('inert');Build the component: React + TypeScript implementation
Here is a production-leaning modal implementation with a reusable useFocusTrap hook, portal rendering, full ARIA semantics, Escape handling, and focus restoration. The hook handles the focus logic; the component handles rendering and composition.
useFocusTrap hook
import { useEffect, useRef, useCallback } from 'react';
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"]):not([disabled])',
].join(', ');
export function useFocusTrap(isOpen: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
// Capture the triggering element before the modal opens
useEffect(() => {
if (isOpen) {
triggerRef.current = document.activeElement as HTMLElement;
}
}, [isOpen]);
// Focus the first focusable element (or the container)
useEffect(() => {
if (!isOpen || !containerRef.current) return;
const focusable = containerRef.current.querySelectorAll<HTMLElement>(FOCUSABLE);
const firstTarget = focusable[0] ?? containerRef.current;
// Slight delay lets React finish rendering portal content
requestAnimationFrame(() => firstTarget.focus());
}, [isOpen]);
// Trap Tab / Shift+Tab within the container
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!containerRef.current) return;
if (e.key === 'Tab') {
const nodes = Array.from(
containerRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)
).filter((el) => el.offsetParent !== null);
if (nodes.length === 0) return;
const first = nodes[0];
const last = nodes[nodes.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
},
[]
);
// Restore focus when the modal closes
useEffect(() => {
return () => {
// Runs on unmount or when isOpen changes to false
triggerRef.current?.focus();
};
}, [isOpen]);
return { containerRef, handleKeyDown };
}Modal component
import { createPortal } from 'react-dom';
import { useFocusTrap } from './use-focus-trap';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
description?: string;
children: React.ReactNode;
}
export function Modal({
isOpen,
onClose,
title,
description,
children,
}: ModalProps) {
const { containerRef, handleKeyDown } = useFocusTrap(isOpen);
if (!isOpen) return null;
return createPortal(
{/* Backdrop */}
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
// Close on backdrop click (not drag)
if (e.target === e.currentTarget) onClose();
}}
>
<div className="absolute inset-0 bg-black/50" aria-hidden="true" />
{/* Dialog */}
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby={description ? 'modal-desc' : undefined}
onKeyDown={(e) => {
handleKeyDown(e);
if (e.key === 'Escape') onClose();
}}
className="relative z-10 w-full max-w-lg rounded-xl bg-white p-6
shadow-xl dark:bg-gray-900"
>
<h2 id="modal-title" className="text-lg font-semibold">
{title}
</h2>
{description && (
<p id="modal-desc" className="mt-1 text-sm text-gray-500">
{description}
</p>
)}
<div className="mt-4">{children}</div>
<button
type="button"
onClick={onClose}
className="absolute right-3 top-3 rounded p-1 text-gray-400
hover:text-gray-600"
aria-label="Close dialog"
>
✕
</button>
</div>
</div>,
document.body
);
}The hook owns all focus logic (capture, trap, restore). The component owns rendering, semantics, and close behavior. This separation means you can swap the visual implementation — different styling, animation library, layout — without touching the accessibility layer.
The silent failures: common developer mistakes
These mistakes pass visual QA and even some automated checks, but break the experience for keyboard and assistive technology users.
Mistake 1: Autofocusing a non-interactive element without making it focusable
What breaks. You call .focus() on a <div> or <h2>, but the element has no tabIndex. The browser ignores the call. Focus stays on the background.
Why it happens. Developers assume any element can receive focus. Only interactive elements (buttons, inputs, links) and elements with an explicit tabIndex are focusable.
Fix. Add tabIndex={-1} to the element you want to focus programmatically. The -1 value makes it focusable via JavaScript but keeps it out of the natural tab order.
Mistake 2: Hiding the modal visually while leaving focusable elements in the DOM
What breaks. The modal is hidden with opacity: 0 or transform: translateX(-9999px), but its buttons and inputs remain in the tab order. Keyboard users tab into invisible controls.
Why it happens. CSS-only hide patterns are used to enable enter/exit animations. The closing animation finishes visually, but the DOM is not cleaned up.
Fix. Conditionally render the modal (return null when closed), or use display: none / visibility: hidden after the animation completes. If using transitions, listen for onTransitionEnd before removing the element from the tab order.
Mistake 3: Static focus-trap queries that miss dynamic content
What breaks. The focus trap queries focusable elements once on mount. If new interactive elements appear later (a loading spinner resolves into form fields, a conditional section expands), they are excluded from the trap. Tab wraps past them, and the user cannot reach them.
Why it happens. Focus-trap logic is often written as a one-time setup in a useEffect with no dependencies on content changes.
Fix. Re-query focusable elements on every keydownevent inside the trap handler, not on mount. This is what the implementation above does — the handleKeyDown callback queries the container live on every Tab press.
Mistake 4: Not restoring focus on close
What breaks. The modal closes and focus jumps to the top of the page or an arbitrary element. The user loses their place in a long form or content flow.
Why it happens. Focus restoration is often forgotten entirely, or document.activeElement is captured at the wrong time (after the modal has already moved focus away from the trigger).
Fix. Capture document.activeElement before moving focus into the modal. Store it in a ref. Call .focus() on it in the cleanup function or close handler.
Mistake 5: Misapplying aria-hidden to the modal instead of the background
What breaks. The modal itself is marked aria-hidden="true", making it completely invisible to screen readers. Or aria-hidden is applied to the wrong container, hiding neither the right content nor the right scope.
Why it happens. Confusion about what aria-hidden does. It hides content from assistive technology, not from visual display.
Fix. Apply aria-hidden="true" (or the inert attribute) to the backgroundcontent container while the modal is open — never to the modal itself. Remove it when the modal closes.
How to audit your modals
Testing modal accessibility requires both automated checks and manual verification. Neither alone is sufficient.
Manual testing: the no-mouse challenge
Unplug your mouse (or disable your trackpad) and test every modal in your application using only the keyboard:
- Trigger the modal with
EnterorSpaceon the button. Confirm focus moves into the dialog. - Press
Tabthrough every interactive element. Confirm focus stays inside the modal and wraps correctly. - Press
Shift+Tabto reverse. Confirm it wraps from the first element to the last. - Press
Escape. Confirm the modal closes (where expected). - Confirm focus returns to the exact trigger button.
If you have access to a screen reader (VoiceOver on macOS, NVDA on Windows), test at least one modal flow end-to-end. Listen for the dialog role announcement, the heading, and verify that background content is not announced while the modal is open.
Automated testing
Tools like axe-core (via browser extension, Playwright integration, or CI pipeline) catch structural issues: missing ARIA labels, incorrect roles, duplicate IDs, and color contrast failures inside the dialog.
But automated checks cannot verify focus behavior. They cannot confirm that focus moves on open, stays trapped, or returns on close. Those behaviors require interaction-level testing.
For a deeper dive on building an automated testing workflow, see A Practical Guide to Automated Accessibility Testing. To understand why these checks matter as part of broader code health, Why Code Audits Matter More Than You Think covers the systemic perspective.
Catch structural issues before they ship
Accessible modal checklist
Use this as a pre-ship review checklist for every modal component:
| Requirement | Status |
|---|---|
| Focus moves into the modal on open | ☐ |
| Tab and Shift+Tab cycle only within the modal | ☐ |
| Escape closes the modal (for dismissible dialogs) | ☐ |
| Focus returns to the trigger element on close | ☐ |
role="dialog" or role="alertdialog" is set | ☐ |
aria-modal="true" is set | ☐ |
aria-labelledby points to a visible heading | ☐ |
| Background content is inert (not focusable or clickable) | ☐ |
| Close button has an accessible label | ☐ |
| Tested with keyboard only (no mouse) | ☐ |
Conclusion
Accessibility is part of interaction design, not a polish step you add before release. Focus management is not an enhancement — it is a baseline requirement for modals that actually work for everyone who uses them.
The pattern is consistent and repeatable: move focus in, keep it trapped, support Escape, restore focus on close. Once you build this into your component library or design system, every modal inherits the same reliable behavior.
The best modal implementation is the one that preserves context, maintains control, and creates predictable keyboard behavior — not just for compliance, but because that is what a well-built interface does.
For a broader view of how automated testing fits into your accessibility workflow, read A Practical Guide to Automated Accessibility Testing. And for the case that code-level reviews are worth the investment beyond just catching bugs, Why Code Audits Matter More Than You Think covers the full picture.
Not sure what your current pages are missing? Run a Website Audit to catch structural accessibility and quality issues across your site before they reach production.



