Building an Accessible React Modal Dialog with WAI-ARIA, Focus Management, and Portals

Build an accessible React modal dialog using WAI-ARIA, focus trapping, portals, keyboard support, scroll locking, and robust testing with axe and RTL.

ASOasis
8 min read
Building an Accessible React Modal Dialog with WAI-ARIA, Focus Management, and Portals

Image used for representation purposes only.

Why accessible modals matter

A modal dialog interrupts the current workflow to request a decision, show critical information, or collect input. Because it takes over keyboard and screen-reader focus, it must be implemented with care. An inaccessible modal can trap users, hide context from assistive tech, or make escape impossible. This guide walks through a robust, production-ready React modal that follows WAI-ARIA Authoring Practices and works with keyboards, screen readers, and touch.

Requirements checklist

Before writing code, align on behavior:

  • Semantics
    • role=‘dialog’ (or ‘alertdialog’ for urgent, blocking content)
    • aria-modal=‘true’
    • Accessible name via aria-labelledby or aria-label
    • Optional description via aria-describedby
  • Focus
    • Move focus inside the dialog when it opens
    • Trap focus within while open (Tab/Shift+Tab cycle)
    • Restore focus to the previously focused element when it closes
  • Keyboard
    • Esc closes (unless the flow forbids it)
    • Tab/Shift+Tab traverse only focusable elements within the dialog
  • Visibility
    • Hide content outside the dialog from assistive tech
    • Prevent background page from scrolling while open
  • Pointer
    • Optional: click backdrop to close without stealing focus
  • Structure
    • Render via a portal to document.body to escape CSS stacking and z-index issues
  • Performance & resilience
    • SSR-friendly, no crashes if document/window are unavailable at import time

Architecture overview

We will build a component that:

  • Portals its markup to a container div appended to document.body
  • Applies role=‘dialog’ and aria-modal=‘true’
  • Accepts labelledBy and describedBy IDs for the dialog title and description
  • Traps focus with a small utility
  • Locks scrolling using body position locking (no layout jump)
  • Hides background from assistive tech with aria-hidden on body siblings
  • Restores focus to the element that opened the modal

Implementation (TypeScript + React 18)

The implementation is intentionally dependency-light. You can replace pieces with libraries like focus-trap, react-remove-scroll, or aria-hidden if preferred.

// Modal.tsx
import React, {useEffect, useLayoutEffect, useRef, useId} from 'react';
import {createPortal} from 'react-dom';

type ModalProps = {
  isOpen: boolean;
  onClose: () => void;
  children: React.ReactNode;
  labelledBy?: string; // id of the heading inside the dialog
  describedBy?: string; // id of descriptive text
  initialFocusRef?: React.RefObject<HTMLElement>;
  closeOnEsc?: boolean;
  closeOnBackdrop?: boolean;
};

const FOCUSABLE_SELECTOR = [
  'a[href]',
  'button:not([disabled])',
  'textarea:not([disabled])',
  'input:not([disabled])',
  'select:not([disabled])',
  '[tabindex]:not([tabindex="-1"])'
].join(',');

function useIsomorphicLayoutEffect(cb: React.EffectCallback, deps: React.DependencyList) {
  return typeof window !== 'undefined' ? useLayoutEffect(cb, deps) : useEffect(cb, deps);
}

export function Modal({
  isOpen,
  onClose,
  children,
  labelledBy,
  describedBy,
  initialFocusRef,
  closeOnEsc = true,
  closeOnBackdrop = true
}: ModalProps) {
  const portalElRef = useRef<HTMLElement | null>(null);
  const dialogRef = useRef<HTMLDivElement>(null);
  const previouslyFocusedRef = useRef<HTMLElement | null>(null);

  // Lazily create the portal element
  useIsomorphicLayoutEffect(() => {
    if (!portalElRef.current && typeof document !== 'undefined') {
      const el = document.createElement('div');
      el.setAttribute('data-modal-root', '');
      portalElRef.current = el as HTMLElement;
      document.body.appendChild(el);
    }
    return () => {
      if (portalElRef.current) {
        portalElRef.current.remove();
        portalElRef.current = null;
      }
    };
  }, []);

  // Hide background from assistive tech when open
  useEffect(() => {
    if (!isOpen || typeof document === 'undefined' || !portalElRef.current) return;
    const siblings = Array.from(document.body.children);
    const others = siblings.filter(el => el !== portalElRef.current);
    const prev: Array<{el: Element; ariaHidden: string | null}> = [];
    others.forEach(el => {
      prev.push({el, ariaHidden: el.getAttribute('aria-hidden')});
      el.setAttribute('aria-hidden', 'true');
    });
    return () => {
      prev.forEach(({el, ariaHidden}) => {
        if (ariaHidden === null) el.removeAttribute('aria-hidden');
        else el.setAttribute('aria-hidden', ariaHidden);
      });
    };
  }, [isOpen]);

  // Lock scroll when open (no layout jump)
  useEffect(() => {
    if (!isOpen || typeof document === 'undefined' || typeof window === 'undefined') return;
    const {body} = document;
    const scrollY = window.scrollY;
    const prev = {
      position: body.style.position,
      top: body.style.top,
      width: body.style.width
    };
    body.style.position = 'fixed';
    body.style.top = `-${scrollY}px`;
    body.style.width = '100%';
    return () => {
      body.style.position = prev.position;
      body.style.top = prev.top;
      body.style.width = prev.width;
      window.scrollTo(0, scrollY);
    };
  }, [isOpen]);

  // Manage focus: move in on open, trap while open, restore on close
  useEffect(() => {
    if (!isOpen || typeof document === 'undefined') return;

    previouslyFocusedRef.current = document.activeElement as HTMLElement | null;

    const focusFirst = () => {
      const dialog = dialogRef.current;
      if (!dialog) return;
      const target = initialFocusRef?.current || (dialog.querySelector(FOCUSABLE_SELECTOR) as HTMLElement | null);
      if (target) target.focus();
      else {
        // Make the dialog itself focusable if nothing else is
        dialog.tabIndex = -1;
        dialog.focus();
      }
    };

    // Next tick to ensure portal content is present
    const id = window.setTimeout(focusFirst); // 0ms

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape' && closeOnEsc) {
        e.stopPropagation();
        onClose();
        return;
      }
      if (e.key === 'Tab') {
        const dialog = dialogRef.current;
        if (!dialog) return;
        const focusables = Array.from(dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
          .filter(el => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden'));
        if (focusables.length === 0) {
          e.preventDefault();
          dialog.focus();
          return;
        }
        const first = focusables[0];
        const last = focusables[focusables.length - 1];
        const active = document.activeElement as HTMLElement | null;
        if (!e.shiftKey && active === last) {
          e.preventDefault();
          first.focus();
        } else if (e.shiftKey && active === first) {
          e.preventDefault();
          last.focus();
        }
      }
    };

    document.addEventListener('keydown', handleKeyDown, true);

    return () => {
      window.clearTimeout(id);
      document.removeEventListener('keydown', handleKeyDown, true);
      // Restore focus
      const prev = previouslyFocusedRef.current;
      if (prev && typeof prev.focus === 'function') prev.focus();
      previouslyFocusedRef.current = null;
    };
  }, [isOpen, closeOnEsc, onClose, initialFocusRef]);

  if (!isOpen || !portalElRef.current) return null;

  const onBackdropClick: React.MouseEventHandler<HTMLDivElement> = (e) => {
    if (!closeOnBackdrop) return;
    if (e.target === e.currentTarget) onClose();
  };

  const content = (
    <div
      role='dialog'
      aria-modal='true'
      aria-labelledby={labelledBy}
      aria-describedby={describedBy}
      className='modal-backdrop'
      onMouseDown={onBackdropClick}
      ref={dialogRef}
    >
      <div className='modal-panel' role='document'>
        {children}
      </div>
    </div>
  );

  return createPortal(content, portalElRef.current);
}

Styles (minimal, theme as needed)

/* Prevent body shift when scrollbar disappears is handled in JS */

.modal-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.45);
  display: grid;
  place-items: center;
  padding: 1rem;
  z-index: 1000;
}

.modal-panel {
  max-height: 85vh;
  overflow: auto;
  max-width: 42rem;
  width: 100%;
  background: #fff;
  color: #111;
  border-radius: 0.5rem;
  box-shadow: 0 10px 30px rgba(0,0,0,0.35);
  padding: 1rem 1.25rem;
}

.visually-hidden {
  position: absolute !important;
  height: 1px; width: 1px;
  overflow: hidden;
  clip: rect(1px, 1px, 1px, 1px);
  white-space: nowrap;
}

@media (prefers-reduced-motion: no-preference) {
  .modal-backdrop { animation: fadeIn .12s ease-out; }
  .modal-panel { animation: scaleIn .14s ease-out; transform-origin: 50% 45%; }
  @keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
  @keyframes scaleIn { from { transform: scale(.98); opacity: 0 } to { transform: scale(1); opacity: 1 } }
}

Usage example

// App.tsx
import React, {useRef, useState} from 'react';
import {Modal} from './Modal';

export default function App() {
  const [open, setOpen] = useState(false);
  const headingId = 'settings-title';
  const descId = 'settings-desc';
  const firstFieldRef = useRef<HTMLInputElement>(null);

  return (
    <div className='page'>
      <h1>Dashboard</h1>
      <button onClick={() => setOpen(true)}>Open settings</button>

      <Modal
        isOpen={open}
        onClose={() => setOpen(false)}
        labelledBy={headingId}
        describedBy={descId}
        initialFocusRef={firstFieldRef}
      >
        <header style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}>
          <h2 id={headingId}>Settings</h2>
          <button aria-label='Close dialog' onClick={() => setOpen(false)}>Ă—</button>
        </header>
        <p id={descId}>Update your preferences. All fields are optional.</p>
        <form>
          <label>
            Display name
            <input ref={firstFieldRef} type='text' />
          </label>
          <label>
            Email notifications
            <select>
              <option>All</option>
              <option>Important only</option>
              <option>None</option>
            </select>
          </label>
          <div style={{marginTop: '1rem'}}>
            <button type='submit'>Save</button>
            <button type='button' onClick={() => setOpen(false)}>Cancel</button>
          </div>
        </form>
      </Modal>
    </div>
  );
}

Testing accessibility

  • Screen readers
    • VoiceOver (macOS/iOS), NVDA or JAWS (Windows), TalkBack (Android)
    • Verify: focus moves into dialog; title is announced; background is not navigable; Esc closes; focus returns to the trigger
  • Keyboard-only
    • Tab/Shift+Tab loop inside dialog
    • No focus is possible behind the backdrop
  • Automated checks
    • jest-axe or @axe-core/react for static rules
    • @testing-library/react + user-event for keyboard flows

Example test skeleton:

import {render, screen} from '@testing-library/react';
import user from '@testing-library/user-event';
import {Modal} from './Modal';

// …render wrapper that toggles isOpen…

it('traps focus and restores it on close', async () => {
  const userEvent = user.setup();
  // open modal
  // expect focus to be inside
  await userEvent.tab();
  // …
  await userEvent.keyboard('{Escape}');
  // expect focus restored to trigger
});

Common pitfalls and how to avoid them

  • Missing accessible name
    • Ensure the dialog has an accessible name via aria-labelledby or aria-label. Prefer tying it to a visible heading inside the dialog.
  • Forgetting to restore focus
    • Always capture document.activeElement before opening and restore it in cleanup.
  • Not hiding background from assistive tech
    • aria-modal=‘true’ is helpful but does not hide all content for all AT. Proactively set aria-hidden=‘true’ on body siblings (or use the inert attribute with a polyfill if you rely on it).
  • Scroll jumps on open/close
    • Use body position locking, not just overflow: hidden, to prevent layout jumps on some mobile browsers.
  • Overzealous backdrop clicks
    • Only close when the backdrop itself is clicked (e.target === e.currentTarget) so clicks inside the panel don’t close the dialog.
  • Using display: none to hide open dialogs
    • If the dialog is open, it must be in the accessibility tree. When closed, unmount it to remove from tab order and the tree.
  • Nesting modals
    • Avoid stacking dialogs; it complicates focus and announcements. Use wizard steps or inline expansions instead.

Enhancements and variations

  • Alert dialogs
    • For urgent confirmation, change role to ‘alertdialog’ and ensure the primary action is focused on open. Keep Esc-to-close consistent with your pattern library.
  • Return focus to a specific element
    • Allow a prop to pass a focus return target if it differs from previouslyFocusedRef.
  • Transitions
    • Respect prefers-reduced-motion and keep animations short and subtle. Ensure focus moves immediately; visual transitions should not delay focus.
  • Accessibility annotations
    • If the dialog contains dynamic status updates, use aria-live regions inside the dialog, not outside it.

Native alternative

The HTML

element is a viable choice today. It provides showModal(), inert-like background behavior, and escape handling. You still need labeling, focus management for complex content, and consistent styling. A minimal React wrapper might look like this:

function NativeDialog({open, onClose, labelledBy, children}: {
  open: boolean; onClose: () => void; labelledBy?: string; children: React.ReactNode;
}) {
  const ref = React.useRef<HTMLDialogElement>(null);
  useEffect(() => {
    const d = ref.current; if (!d) return;
    if (open && !d.open) d.showModal();
    if (!open && d.open) d.close();
  }, [open]);
  useEffect(() => {
    const d = ref.current; if (!d) return;
    const onCancel = (e: Event) => { e.preventDefault(); onClose(); };
    d.addEventListener('cancel', onCancel);
    return () => d.removeEventListener('cancel', onCancel);
  }, [onClose]);
  return (
    <dialog ref={ref} aria-labelledby={labelledBy}>
      {children}
    </dialog>
  );
}

Choose this route if you’re comfortable with its styling quirks and need less custom behavior. Keep the same labeling and testing discipline.

Conclusion

An accessible modal is less about visuals and more about semantics, focus, and predictable behavior. By combining ARIA roles, proper labeling, careful focus management, background hiding, and scroll locking, you create a dialog that works for everyone. Start with the lightweight implementation above, add tests, and grow it into a reusable component in your design system.

Related Posts