ESC

Type to search the knowledge base.

Accessible Modal Dialog

Machine-coding brief for a modal dialog — focus trap, Escape, scroll lock, restore focus, and ARIA dialog pattern.

intermediate5 min read
  • machine-coding
  • react
  • a11y
  • focus
  • interview

Problem statement

Build an accessible modal dialog. Opening it should focus inside, trap Tab focus, close on Escape and optional backdrop click, restore focus to the opener, and expose the correct ARIA semantics. This is a favorite machine-coding / UI-craft round because most candidates ship a “centered div” and fail keyboard users.

Requirements

Must have

  • Open / close controlled or uncontrolled API
  • Overlay (backdrop) + dialog panel
  • Initial focus moves into the dialog (first focusable or labeled container)
  • Focus trap while open (Tab / Shift+Tab cycle)
  • Escape closes
  • Focus returns to the element that opened the dialog
  • role="dialog" + aria-modal="true" + labelled by title
  • Body scroll lock while open
  • Close button with an accessible name

Should have

  • Optional confirm / cancel actions
  • Disable backdrop close for destructive flows (closeOnBackdropClick)
  • Portal to document.body

Nice to have

  • Enter/exit animation with prefers-reduced-motion
  • Nested dialog stacking
  • Native <dialog> + showModal() variant discussion

Planning (5 minutes out loud)

  1. API — open, onOpenChange, title, children
  2. Focus — save previously focused node; focus first tabbable on open
  3. Trap — keydown on Tab at edges
  4. Portal — render above app stacking context
  5. MVP — open/close + Escape + focus restore; then trap + scroll lock

Architecture

Modal (portal)
├── Backdrop
└── Dialog panel
    ├── Header (title + close)
    ├── Body
    └── Footer (actions)

Component API

type ModalProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  title: string;
  children: React.ReactNode;
  closeOnBackdropClick?: boolean; // default true
  initialFocusRef?: React.RefObject<HTMLElement | null>;
};

Implementation sketch

Portal + scroll lock + Escape

function Modal({
  open,
  onOpenChange,
  title,
  children,
  closeOnBackdropClick = true,
  initialFocusRef,
}: ModalProps) {
  const panelRef = useRef<HTMLDivElement>(null);
  const titleId = useId();
  const previouslyFocused = useRef<HTMLElement | null>(null);

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

    previouslyFocused.current = document.activeElement as HTMLElement | null;
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";

    const toFocus =
      initialFocusRef?.current ??
      panelRef.current?.querySelector<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      ) ??
      panelRef.current;

    toFocus?.focus();

    function onKeyDown(e: KeyboardEvent) {
      if (e.key === "Escape") {
        e.stopPropagation();
        onOpenChange(false);
      }
    }
    document.addEventListener("keydown", onKeyDown);

    return () => {
      document.body.style.overflow = prevOverflow;
      document.removeEventListener("keydown", onKeyDown);
      previouslyFocused.current?.focus?.();
    };
  }, [open, onOpenChange, initialFocusRef]);

  useFocusTrap(panelRef, open);

  if (!open) return null;

  return createPortal(
    <div className="modal-root">
      <div
        className="modal-backdrop"
        onClick={() => {
          if (closeOnBackdropClick) onOpenChange(false);
        }}
      />
      <div
        ref={panelRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        className="modal-panel"
        onClick={(e) => e.stopPropagation()}
      >
        <header>
          <h2 id={titleId}>{title}</h2>
          <button type="button" aria-label="Close dialog" onClick={() => onOpenChange(false)}>
            ×
          </button>
        </header>
        <div>{children}</div>
      </div>
    </div>,
    document.body
  );
}

Focus trap (Tab cycle)

function useFocusTrap(containerRef: React.RefObject<HTMLElement | null>, enabled: boolean) {
  useEffect(() => {
    if (!enabled) return;

    function onKeyDown(e: KeyboardEvent) {
      if (e.key !== "Tab") return;
      const root = containerRef.current;
      if (!root) return;

      const focusables = root.querySelectorAll<HTMLElement>(
        'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
      );
      const list = Array.from(focusables).filter((el) => !el.hasAttribute("disabled"));
      if (list.length === 0) {
        e.preventDefault();
        root.focus();
        return;
      }

      const first = list[0];
      const last = list[list.length - 1];
      const active = document.activeElement as HTMLElement | null;

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

    document.addEventListener("keydown", onKeyDown, true);
    return () => document.removeEventListener("keydown", onKeyDown, true);
  }, [containerRef, enabled]);
}

Give the panel tabIndex={-1} if it may receive focus when there are no tabbables.

Usage

function Example() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button type="button" onClick={() => setOpen(true)}>
        Delete item
      </button>
      <Modal open={open} onOpenChange={setOpen} title="Delete item?" closeOnBackdropClick={false}>
        <p>This cannot be undone.</p>
        <button type="button" onClick={() => setOpen(false)}>
          Cancel
        </button>
        <button type="button">Delete</button>
      </Modal>
    </>
  );
}

Accessibility notes

Rule Implementation
Name aria-labelledby → visible title
Modal aria-modal="true" (hides background from AT in supporting browsers)
Focus Enter: first control; Exit: restore opener
Keyboard Escape, Tab trap
Backdrop Optional click; never the only close method
Motion Respect prefers-reduced-motion

Prefer referencing APG Dialog (Modal) over inventing roles.

Native <dialog>: showModal() gives focus trap and top-layer for free in modern browsers. In interviews, implementing the pattern manually shows understanding; mentioning native is a plus.

Related: Accessible modals, Focus management, Keyboard checklist.

Performance notes

  • Portal once; don’t remount heavy children if you only toggle CSS — but for interviews, conditional render is fine.
  • Avoid useEffect focus loops: depend on open, not on every render.
  • Scroll lock: set overflow: hidden on body; restore previous value exactly.

Interview expectations

Signal What good looks like
A11y first Roles + labels without prompting
Focus Save / move / restore
Trap Tab cycles; Escape works
API Controlled open / onOpenChange
Edge No focusables; backdrop policy
Time Working modal in ~30 min, trap polished after

Extensions they may ask live

  1. Nested modals (stack of restore targets)
  2. Drawers / non-modal popovers (aria-modal false, different dismiss rules)
  3. Form dialog with validation error focus
  4. Animation + remove from tab order when closed

A modal that only works with a mouse is incomplete. Ship focus, Escape, and restore as part of MVP — not “if time.”