Building accessible modals in React
Table of contents
Modal dialogs are everywhere in web applications — confirmation prompts, forms, image viewers, settings panels. But most custom implementations break accessibility by failing to manage focus, trap keyboard navigation, or communicate their role to screen readers.
This guide walks through building an accessible modal in React that meets WCAG 2.2(opens in a new tab) Level AA. It’ll start with the native <dialog> element (the recommended approach in 2026), and then cover custom implementations for when you need more control.
What’s wrong with most modals
A basic modal that toggles visibility with state and uses position: fixed fails several WCAG requirements:
- No focus management — Focus stays behind the modal, so keyboard users can’t reach the content (2.4.3 Focus Order(opens in a new tab)).
- No focus trapping — Tab moves focus outside the modal to background elements (2.1.2 No Keyboard Trap(opens in a new tab)).
- No screen reader isolation — Assistive technologies can read content behind the modal (4.1.2 Name, Role, Value(opens in a new tab)).
- No ESC to close — Keyboard users have no way to dismiss the modal (2.1.1 Keyboard(opens in a new tab)).
- No ARIA role — Screen readers don’t know it’s a dialog.
- No focus restoration — Closing the modal leaves focus in an unpredictable location.
- No scroll locking — Users can scroll the background content while the modal is open.
The native <dialog> element solves most of these out of the box.
The native dialog element
The HTML <dialog> element, invoked with showModal(), is the recommended foundation for modals in 2026. It provides built-in:
- Focus trapping — Tab cycles within the dialog without extra JavaScript.
- ESC to close — The browser handles it natively.
aria-modal="true"semantics — Screen readers know to ignore background content.- Top-layer rendering — No
z-indexbattles, no portals needed. - Scroll locking — Background content can’t scroll while the dialog is open.
::backdroppseudo-element — For styling the overlay behind the dialog.
Browser support(opens in a new tab) is universal across Chrome, Firefox, Safari, and Edge.
Basic dialog in React
import { useRef, useEffect, useId } from 'react';
function Modal({ isOpen, onClose, title, children }) { const dialogRef = useRef(null); const headingId = useId();
useEffect(() => { const dialog = dialogRef.current; if (!dialog) return;
if (isOpen) { dialog.showModal(); } else { dialog.close(); } }, [isOpen]);
return ( <dialog ref={dialogRef} onClose={onClose} aria-labelledby={headingId} > <h2 id={headingId}>{title}</h2> {children} <button onClick={onClose}>Close</button> </dialog> );}Note the use of useId() for the aria-labelledby reference. This hook generates stable, SSR-safe IDs that work correctly with portals — it’s the standard way to connect ARIA attributes in React.
Labeling the dialog
Every dialog needs an accessible name. Use aria-labelledby to reference a visible heading:
const headingId = useId();const descriptionId = useId();
<dialog ref={dialogRef} onClose={onClose} aria-labelledby={headingId} aria-describedby={descriptionId}> <h2 id={headingId}>Delete document</h2> <p id={descriptionId}> This action cannot be undone. The document and all its annotations will be permanently removed. </p> <button onClick={onConfirm}>Delete</button> <button onClick={onClose}>Cancel</button></dialog>If the dialog has no visible heading, use aria-label instead:
<dialog ref={dialogRef} aria-label="Search documents"> <input type="search" placeholder="Search..." /></dialog>Dialog role vs. alertdialog role
The <dialog> element already has an implicit role="dialog". For confirmation prompts that interrupt the user and require a response — like delete confirmations — override it with role="alertdialog". Alert dialogs shouldn’t close when clicking the backdrop:
<dialog ref={dialogRef} role="alertdialog" aria-labelledby={headingId}> <h2 id={headingId}>Unsaved changes</h2> <p>You have unsaved changes. Discard them?</p> <button onClick={onDiscard}>Discard</button> <button onClick={onClose}>Keep editing</button></dialog>Where to place initial focus
The WAI-ARIA Authoring Practices Guide(opens in a new tab) recommends context-dependent focus placement, and not always the first focusable element:
- Confirmation/destructive dialogs — Focus the least destructive action (e.g. “Cancel,” not “Delete”).
- Form dialogs — Focus the first input field.
- Content-heavy dialogs — Focus a static element (like the heading or dialog container) so the user hears the context first.
Use autoFocus on the target element, or set focus manually:
function ConfirmDialog({ isOpen, onClose, onDelete }) { const dialogRef = useRef(null); const cancelRef = useRef(null); const headingId = useId();
useEffect(() => { const dialog = dialogRef.current; if (!dialog) return;
if (isOpen) { dialog.showModal(); // Focus the least destructive action. cancelRef.current?.focus(); } else { dialog.close(); } }, [isOpen]);
return ( <dialog ref={dialogRef} aria-labelledby={headingId}> <h2 id={headingId}>Delete document?</h2> <p>This cannot be undone.</p> <button onClick={onDelete}>Delete</button> <button ref={cancelRef} onClick={onClose}> Cancel </button> </dialog> );}Restoring focus on close
When the dialog closes, return focus to the element that triggered it. Store a reference to document.activeElement before opening:
import { useRef, useEffect, useId } from 'react';
function Modal({ isOpen, onClose, title, children }) { const dialogRef = useRef(null); const triggerRef = useRef(null); const headingId = useId();
useEffect(() => { const dialog = dialogRef.current; if (!dialog) return;
if (isOpen) { triggerRef.current = document.activeElement; dialog.showModal(); } else { dialog.close(); if (triggerRef.current) { triggerRef.current.focus(); triggerRef.current = null; } } }, [isOpen]);
return ( <dialog ref={dialogRef} onClose={onClose} aria-labelledby={headingId} > <h2 id={headingId}>{title}</h2> {children} <button onClick={onClose}>Close</button> </dialog> );}Closing on backdrop click
The native <dialog> doesn’t close on backdrop click by default. Add it by checking if the click target is the dialog itself (the backdrop area):
function handleBackdropClick(event) { if (event.target === dialogRef.current) { onClose(); }}
<dialog ref={dialogRef} onClick={handleBackdropClick}> {children}</dialog>Don’t add backdrop close to role="alertdialog" — alert dialogs require an explicit user response.
Forms inside dialogs
The native <dialog> supports <form method="dialog">, which closes the dialog on submit and sets the dialog’s returnValue to the submit button’s value:
function ConfirmDialog({ isOpen, onClose }) { const dialogRef = useRef(null); const headingId = useId();
useEffect(() => { const dialog = dialogRef.current; if (!dialog) return;
if (isOpen) { dialog.showModal(); } else { dialog.close(); } }, [isOpen]);
function handleClose() { const result = dialogRef.current?.returnValue; onClose(result); // "confirm" or "cancel" }
return ( <dialog ref={dialogRef} onClose={handleClose} aria-labelledby={headingId} > <h2 id={headingId}>Save changes?</h2> <form method="dialog"> <button value="confirm">Save</button> <button value="cancel">Cancel</button> </form> </dialog> );}Styling the backdrop and animations
Style the overlay with the ::backdrop pseudo-element:
dialog::backdrop { background: rgba(0, 0, 0, 0.5);}For open/close animations, use the @starting-style rule. Always respect prefers-reduced-motion (WCAG 2.3.3(opens in a new tab)):
dialog { opacity: 0; transform: translateY(1rem); transition: opacity 200ms ease, transform 200ms ease, overlay 200ms ease allow-discrete, display 200ms ease allow-discrete;}
dialog[open] { opacity: 1; transform: translateY(0);}
@starting-style { dialog[open] { opacity: 0; transform: translateY(1rem); }}
@media (prefers-reduced-motion: reduce) { dialog { transition: none; }}Custom modals (when you need more control)
The native <dialog> covers most use cases. But if you need custom animations, non-modal dialogs, or specific stacking behavior, you may need a custom implementation. You’ll need to handle everything <dialog> gives you for free.
Layering with React portals
Render the modal outside the component tree using createPortal to avoid z-index stacking issues:
import { createPortal } from 'react-dom';
function Modal({ isOpen, children }) { if (!isOpen) return null;
return createPortal( <div className="modal-overlay"> <div className="modal-content"> {children} </div> </div>, document.body );}Isolation with inert
The inert attribute disables all interaction and hides content from assistive technologies. Apply it to the main app container when the modal opens:
import { useEffect } from 'react';
function useInert(isOpen) { useEffect(() => { const appRoot = document.getElementById('root'); if (!appRoot) return;
if (isOpen) { appRoot.setAttribute('inert', ''); } else { appRoot.removeAttribute('inert'); }
return () => appRoot.removeAttribute('inert'); }, [isOpen]);}The inert attribute is supported in all major browsers(opens in a new tab). It’s more robust than aria-hidden="true" alone because it blocks all interaction, not just screen reader access.
Pair it with aria-modal="true" on the dialog container for maximum compatibility:
<div role="dialog" aria-modal="true" aria-labelledby={headingId}> {children}</div>Focus trapping
Focus must cycle within the modal. When the user presses Tab on the last focusable element, focus moves to the first — and vice versa with Shift + Tab:
import { useCallback } from 'react';
function useFocusTrap(modalRef) { const handleKeyDown = useCallback( (event) => { if (event.key !== 'Tab') return;
const focusableElements = modalRef.current?.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' );
if (!focusableElements || focusableElements.length === 0) return;
const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) { if (document.activeElement === firstElement) { event.preventDefault(); lastElement.focus(); } } else { if (document.activeElement === lastElement) { event.preventDefault(); firstElement.focus(); } } }, [modalRef] );
return handleKeyDown;}For production use, consider focus-trap-react(opens in a new tab) instead of a manual implementation — it handles edge cases like dynamically added elements and shadow DOM.
Scroll locking
Prevent background scrolling when the modal is open. The simple approach of overflow: hidden on the body has issues on iOS (scroll position jumps). Use the position: fixed workaround instead:
import { useEffect } from 'react';
function useScrollLock(isOpen) { useEffect(() => { if (!isOpen) return;
const scrollY = window.scrollY; document.body.style.position = 'fixed'; document.body.style.top = `-${scrollY}px`; document.body.style.width = '100%';
return () => { document.body.style.position = ''; document.body.style.top = ''; document.body.style.width = ''; window.scrollTo(0, scrollY); }; }, [isOpen]);}ESC to close
import { useEffect } from 'react';
function useEscapeKey(isOpen, onClose) { useEffect(() => { if (!isOpen) return;
function handleKeyDown(event) { if (event.key === 'Escape') { onClose(); } }
document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, onClose]);}Complete custom modal
Here’s the full custom implementation with all hooks combined:
import { useState, useEffect, useRef, useCallback, useId,} from 'react';import { createPortal } from 'react-dom';
function Modal({ isOpen, onClose, role = 'dialog', title, description, children,}) { const modalRef = useRef(null); const triggerRef = useRef(null); const headingId = useId(); const descriptionId = useId();
// Inert and scroll lock. useEffect(() => { if (!isOpen) return;
const appRoot = document.getElementById('root'); const scrollY = window.scrollY;
appRoot?.setAttribute('inert', ''); document.body.style.position = 'fixed'; document.body.style.top = `-${scrollY}px`; document.body.style.width = '100%';
return () => { appRoot?.removeAttribute('inert'); document.body.style.position = ''; document.body.style.top = ''; document.body.style.width = ''; window.scrollTo(0, scrollY); }; }, [isOpen]);
// Focus management. useEffect(() => { if (isOpen) { triggerRef.current = document.activeElement; const focusable = modalRef.current?.querySelector( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); if (focusable) { focusable.focus(); } else { modalRef.current?.focus(); } } else if (triggerRef.current) { triggerRef.current.focus(); triggerRef.current = null; } }, [isOpen]);
// Keyboard events. const handleKeyDown = useCallback( (event) => { if (event.key === 'Escape') { onClose(); return; }
if (event.key !== 'Tab') return;
const focusableElements = modalRef.current?.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); if (!focusableElements || focusableElements.length === 0) return;
const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1];
if ( event.shiftKey && document.activeElement === firstElement ) { event.preventDefault(); lastElement.focus(); } else if ( !event.shiftKey && document.activeElement === lastElement ) { event.preventDefault(); firstElement.focus(); } }, [onClose] );
if (!isOpen) return null;
return createPortal( <div className="modal-overlay" onClick={onClose}> <div ref={modalRef} role={role} aria-modal="true" aria-labelledby={title ? headingId : undefined} aria-describedby={ description ? descriptionId : undefined } tabIndex={-1} className="modal-content" onClick={(e) => e.stopPropagation()} onKeyDown={handleKeyDown} > {title && <h2 id={headingId}>{title}</h2>} {description && ( <p id={descriptionId}>{description}</p> )} {children} </div> </div>, document.body );}
export default Modal;Usage:
import { useState } from 'react';import Modal from './Modal';
function App() { const [isOpen, setIsOpen] = useState(false);
return ( <> <button onClick={() => setIsOpen(true)}> Open modal </button> <Modal isOpen={isOpen} onClose={() => setIsOpen(false)} title="Edit annotations" description="Make changes to your document annotations." > <button onClick={() => setIsOpen(false)}> Close </button> </Modal> </> );}Screen reader testing
Don’t assume that adding the right ARIA attributes means your modal works everywhere. Real-world screen reader behavior varies:
- Safari + VoiceOver has a known WebKit bug(opens in a new tab) where static content inside modals using
aria-modal="true"may not be accessible. Test with both native<dialog>and custom implementations. - NVDA may not announce the dialog role in some browser configurations.
- TalkBack (Android) can miss absolutely positioned elements inside modals.
Test across at least two combinations:
| Screen reader | Browser | Platform |
|---|---|---|
| NVDA(opens in a new tab) (free) | Firefox or Chrome | Windows |
| VoiceOver | Safari | macOS/iOS |
| JAWS(opens in a new tab) | Chrome | Windows |
| TalkBack | Chrome | Android |
Choosing the right approach
| Approach | Best for | Tradeoffs |
|---|---|---|
Native <dialog> | Most use cases | Less control over animations; showModal() is client-side only |
| Custom with portals | Complex stacking, non-modal dialogs, legacy codebases | Must implement focus trap, scroll lock, ESC, and inert manually |
| Radix Dialog(opens in a new tab) | Product teams using shadcn/ui | Composable API, adds a dependency |
| React Aria(opens in a new tab) | Accessibility-critical applications | Hooks-first, more verbose but most thorough |
| Headless UI(opens in a new tab) | Tailwind CSS projects | Tightly integrated with Tailwind |
For most new projects, start with native <dialog>. Reach for a headless library if you need composable primitives across your design system. Only build a fully custom modal if you have specific requirements that neither approach covers.
WCAG 2.2 checklist for modals
| Requirement | WCAG criterion | Implementation |
|---|---|---|
| Focus moves into modal on open | 2.4.3 Focus Order(opens in a new tab) | Focus first focusable element, or least destructive action |
| Focus trapped within modal | 2.1.2 No Keyboard Trap(opens in a new tab) | Native <dialog> or Tab cycling + ESC to exit |
| ESC closes modal | 2.1.1 Keyboard(opens in a new tab) | Native <dialog> or keydown listener |
| Focus restored on close | 2.4.3 Focus Order(opens in a new tab) | Store and restore trigger element |
| Background hidden from AT | 4.1.2 Name, Role, Value(opens in a new tab) | Native <dialog>, or aria-modal="true" + inert |
| Dialog role announced | 4.1.2 Name, Role, Value(opens in a new tab) | <dialog> (implicit) or role="dialog" / role="alertdialog" |
| Dialog has accessible name | 4.1.2 Name, Role, Value(opens in a new tab) | aria-labelledby or aria-label |
| Focus indicator visible | 2.4.7 Focus Visible(opens in a new tab) | Don’t remove outline styles |
| Focused elements not obscured | 2.4.11 Focus Not Obscured(opens in a new tab) | Modal overlay must not cover focused content |
| Animations respect user preferences | 2.3.3 Animation from Interactions(opens in a new tab) | prefers-reduced-motion: reduce disables transitions |
Conclusion
The native <dialog> element handles focus trapping, ESC to close, scroll locking, and screen reader semantics out of the box — use it as your default. For cases where you need more control, the custom hooks in this guide cover isolation, focus management, and keyboard handling while meeting WCAG 2.2 AA.
Whichever approach you choose, test with real screen readers. VoiceOver, NVDA, and TalkBack each interpret dialog structure differently, and no amount of correct ARIA can substitute for actual testing.
Libraries worth evaluating:
- focus-trap-react(opens in a new tab) — Focus trapping as a React component.
- React Aria(opens in a new tab) — Adobe’s accessible UI hooks.
- Radix Dialog(opens in a new tab) — Unstyled, accessible dialog primitive.
- Headless UI(opens in a new tab) — Tailwind-integrated accessible components.
- a11y-dialog(opens in a new tab) — Lightweight vanilla JS dialog.