Building accessible modals in React

Table of contents

    Building accessible modals in React

    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:

    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-index battles, no portals needed.
    • Scroll locking — Background content can’t scroll while the dialog is open.
    • ::backdrop pseudo-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 readerBrowserPlatform
    NVDA(opens in a new tab) (free)Firefox or ChromeWindows
    VoiceOverSafarimacOS/iOS
    JAWS(opens in a new tab)ChromeWindows
    TalkBackChromeAndroid

    Choosing the right approach

    ApproachBest forTradeoffs
    Native <dialog>Most use casesLess control over animations; showModal() is client-side only
    Custom with portalsComplex stacking, non-modal dialogs, legacy codebasesMust implement focus trap, scroll lock, ESC, and inert manually
    Radix Dialog(opens in a new tab)Product teams using shadcn/uiComposable API, adds a dependency
    React Aria(opens in a new tab)Accessibility-critical applicationsHooks-first, more verbose but most thorough
    Headless UI(opens in a new tab)Tailwind CSS projectsTightly 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

    RequirementWCAG criterionImplementation
    Focus moves into modal on open2.4.3 Focus Order(opens in a new tab)Focus first focusable element, or least destructive action
    Focus trapped within modal2.1.2 No Keyboard Trap(opens in a new tab)Native <dialog> or Tab cycling + ESC to exit
    ESC closes modal2.1.1 Keyboard(opens in a new tab)Native <dialog> or keydown listener
    Focus restored on close2.4.3 Focus Order(opens in a new tab)Store and restore trigger element
    Background hidden from AT4.1.2 Name, Role, Value(opens in a new tab)Native <dialog>, or aria-modal="true" + inert
    Dialog role announced4.1.2 Name, Role, Value(opens in a new tab)<dialog> (implicit) or role="dialog" / role="alertdialog"
    Dialog has accessible name4.1.2 Name, Role, Value(opens in a new tab)aria-labelledby or aria-label
    Focus indicator visible2.4.7 Focus Visible(opens in a new tab)Don’t remove outline styles
    Focused elements not obscured2.4.11 Focus Not Obscured(opens in a new tab)Modal overlay must not cover focused content
    Animations respect user preferences2.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:

    Hulya Masharipov

    Hulya Masharipov

    Technical Writer

    Hulya is a frontend web developer and technical writer who enjoys creating responsive, scalable, and maintainable web experiences. She’s passionate about open source, web accessibility, cybersecurity privacy, and blockchain.

    Explore related topics

    Try for free Ready to get started?