React 52 views

Accessible Modal Dialog with React Hooks

A reusable, accessible modal dialog component using React hooks for managing open/close state.

By TWC Team • Feb 13, 2026

Code

/**
 * Accessible Modal Dialog (React Hooks)
 * Reusable modal that traps focus, restores focus on close, blocks background scroll, and closes on ESC/overlay.
 */
import React, { useState, useEffect, useRef, useCallback } from 'react';
import PropTypes from 'prop-types';

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

const Modal = ({ isOpen, onClose, title, children, initialFocusRef }) => {
  const overlayRef = useRef(null);
  const contentRef = useRef(null);
  const lastActiveRef = useRef(null);

  const close = useCallback(() => onClose?.(), [onClose]);

  useEffect(() => {
    if (!isOpen) return;

    // Prevent background scroll while open; restore on cleanup.
    const { overflow } = document.body.style;
    document.body.style.overflow = 'hidden';
    return () => {
      document.body.style.overflow = overflow || 'unset';
    };
  }, [isOpen]);

  useEffect(() => {
    if (!isOpen) return;

    // Save/restore focus for accessibility.
    lastActiveRef.current = document.activeElement;

    // Focus initial element (preferred ref, otherwise first focusable, otherwise dialog itself).
    const target =
      initialFocusRef?.current ||
      contentRef.current?.querySelector(FOCUSABLE) ||
      contentRef.current;

    // rAF avoids focusing before paint in some browsers.
    const id = requestAnimationFrame(() => target?.focus?.());

    const onKeyDown = (e) => {
      if (e.key === 'Escape') close();
      if (e.key !== 'Tab') return;

      // Trap focus within the dialog.
      const nodes = Array.from(contentRef.current?.querySelectorAll(FOCUSABLE) || []);
      if (!nodes.length) return;

      const first = nodes[0];
      const last = nodes[nodes.length - 1];
      const active = document.activeElement;

      if (e.shiftKey && active === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && active === last) {
        e.preventDefault();
        first.focus();
      }
    };

    document.addEventListener('keydown', onKeyDown);
    return () => {
      cancelAnimationFrame(id);
      document.removeEventListener('keydown', onKeyDown);
      lastActiveRef.current?.focus?.();
    };
  }, [isOpen, close, initialFocusRef]);

  if (!isOpen) return null;

  return (
    <div
      ref={overlayRef}
      className="modal-overlay"
      role="presentation"
      onMouseDown={(e) => e.target === overlayRef.current && close()} // Only close when clicking backdrop.
    >
      <div
        ref={contentRef}
        className="modal-content"
        role="dialog"
        aria-modal="true"
        aria-label={typeof title === 'string' ? title : undefined}
        tabIndex={-1}
        onMouseDown={(e) => e.stopPropagation()}
      >
        <div className="modal-header">
          {title && <div className="modal-title">{title}</div>}
          <button type="button" className="close-button" onClick={close} aria-label="Close dialog">
            ×
          </button>
        </div>
        <div className="modal-body">{children}</div>
      </div>
    </div>
  );
};

Modal.propTypes = {
  isOpen: PropTypes.bool.isRequired,
  onClose: PropTypes.func.isRequired,
  title: PropTypes.node,
  children: PropTypes.node.isRequired,
  initialFocusRef: PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
};

Modal.defaultProps = {
  title: null,
  initialFocusRef: undefined,
};

// Example usage: render any interactive content while preventing background interactions.
const ExampleUsage = () => {
  const [isOpen, setIsOpen] = useState(false);
  const openButtonRef = useRef(null);

  return (
    <div>
      <button ref={openButtonRef} type="button" onClick={() => setIsOpen(true)}>
        Open Modal
      </button>

      <Modal
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        title="Modal Title"
        initialFocusRef={openButtonRef} // Swap for a ref to an input inside the modal if desired.
      >
        <p>This is an accessible modal dialog.</p>
        <button type="button" onClick={() => setIsOpen(false)}>
          Confirm
        </button>
      </Modal>
    </div>
  );
};

export default ExampleUsage;
Back to Snippets