React Curriculum

Accessible Modal Dialog with Focus Trapping & Portals

Hard100% Free~30 mins#react#accessibility#modal#focus-trap#portals#machine-coding
Interview Answer (30-Second Summary)

An accessible Modal Dialog must be rendered into a React Portal, apply role="dialog" and aria-modal="true", trap keyboard Tab focus strictly within the dialog, close on Escape key or backdrop click, prevent background scrolling, and restore focus to the trigger on close.

Key Learning Objectives
✓

Build a fully accessible Modal Dialog adhering to WAI-ARIA Modal Dialog authoring practices.

✓

Implement an active keyboard focus trap that cycles Tab and Shift+Tab between first and last focusable elements.

✓

Prevent background page scrolling by setting `document.body.style.overflow = "hidden"`.

✓

Mount the modal outside the main DOM tree using `ReactDOM.createPortal`.

The Interview Problem & Context

Implement a production-grade `<Modal>` component with Portal rendering, keyboard focus trapping, Escape dismissal, background scroll lock, and trigger focus restoration.

1import React, { useEffect, useRef, ReactNode, KeyboardEvent } from 'react';
2import { createPortal } from 'react-dom';
3
4interface ModalProps {
5 isOpen: boolean;
6 onClose: () => void;
7 title: string;
8 children: ReactNode;
9}
10
11export function AccessibleModal({ isOpen, onClose, title, children }: ModalProps) {
12 const modalRef = useRef<HTMLDivElement>(null);
13 const previouslyFocusedElement = useRef<HTMLElement | null>(null);
14
15 useEffect(() => {
16 if (!isOpen) return;
17
18 // 1. Save currently focused element to restore on close
19 previouslyFocusedElement.current = document.activeElement as HTMLElement;
20
21 // 2. Lock background body scrolling
22 const originalOverflow = document.body.style.overflow;
23 document.body.style.overflow = 'hidden';
24
25 // 3. Move focus to modal or its first focusable child
26 const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(
27 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
28 );
29 if (focusableElements && focusableElements.length > 0) {
30 focusableElements[0].focus();
31 } else {
32 modalRef.current?.focus();
33 }
34
35 // Cleanup on close
36 return () => {
37 document.body.style.overflow = originalOverflow;
38 previouslyFocusedElement.current?.focus();
39 };
40 }, [isOpen]);
41
42 // 4. Focus Trap: Cycle Tab / Shift+Tab within modal
43 const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
44 if (e.key === 'Escape') {
45 e.stopPropagation();
46 onClose();
47 return;
48 }
49
50 if (e.key === 'Tab') {
51 const focusable = modalRef.current?.querySelectorAll<HTMLElement>(
52 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
53 );
54 if (!focusable || focusable.length === 0) return;
55
56 const firstElement = focusable[0];
57 const lastElement = focusable[focusable.length - 1];
58
59 if (e.shiftKey) {
60 // Shift + Tab: If on first element, wrap to last
61 if (document.activeElement === firstElement) {
62 e.preventDefault();
63 lastElement.focus();
64 }
65 } else {
66 // Tab: If on last element, wrap to first
67 if (document.activeElement === lastElement) {
68 e.preventDefault();
69 firstElement.focus();
70 }
71 }
72 }
73 };
74
75 if (!isOpen) return null;
76
77 return createPortal(
78 <div className="modal-backdrop" onClick={onClose}>
79 <div
80 ref={modalRef}
81 role="dialog"
82 aria-modal="true"
83 aria-labelledby="modal-title"
84 tabIndex={-1}
85 className="modal-container"
86 onClick={(e) => e.stopPropagation()} // Prevent closing on modal body click
87 onKeyDown={handleKeyDown}
88 >
89 <header className="modal-header">
90 <h2 id="modal-title">{title}</h2>
91 <button onClick={onClose} aria-label="Close dialog">
92 ✕
93 </button>
94 </header>
95
96 <main className="modal-body">{children}</main>
97 </div>
98 </div>,
99 document.body
100 );
101}
Deep Architectural Explanation

Anatomy of a Production-Ready Accessible Modal

A Modal Dialog is considered accessible only if it satisfies five critical criteria:

  1. Focus Trap:
    • When a user presses Tab on the last focusable element in the dialog, focus wraps to the first element.
    • When pressing Shift + Tab on the first element, focus wraps to the last element.
    • Focus can NEVER escape into the background webpage while the modal is open.
  2. Escape Dismissal:
    • Pressing the Escape key immediately triggers onClose().
  3. Scroll Lock:
    • Setting document.body.style.overflow = "hidden" prevents the background page from scrolling under the modal backdrop on wheel or touch events.
  4. Focus Restoration:
    • When the modal unmounts, focus returns to previouslyFocusedElement.current.
  5. ARIA Attributes:
    • role="dialog"
    • aria-modal="true": Tells assistive technologies to ignore the rest of the document.
    • aria-labelledby="modal-title": Associates the dialog container with the heading element.
Common Interview Traps & Pitfalls
✕

Allowing keyboard focus to tab out of the modal into background navigation links.

✕

Forgetting to restore focus to the button that triggered the modal.

✕

Not restoring `document.body.style.overflow` on unmount, permanently freezing body scroll.

Senior Interviewer Follow-Up Probes
?

How does the native HTML `<dialog>` element and `showModal()` API simplify modal implementation in modern browsers?

?

How do you handle nested modals without corrupting the focus restoration chain?

?

How do you animate modal enter/exit transitions cleanly with CSS without breaking focus trapping?

Frequently Asked Interview Questions
What is a focus trap in a modal dialog?

A mechanism that intercepts Tab and Shift+Tab key presses to keep keyboard focus cycling strictly within the dialog, preventing users from accidentally interacting with underlying page elements.

It signals to assistive technologies (screen readers) that content outside the dialog element is inert and should be hidden from navigation while the dialog is active.

Set document.body.style.overflow = "hidden" when the modal mounts, and restore the original overflow style in the cleanup effect when it unmounts.

Portals mount the modal DOM nodes at the root (document.body), avoiding parent styling issues such as overflow: hidden, clipping, and z-index stacking context restrictions.

Listen for keydown events with e.key === "Escape" on the modal container or window, stop propagation, and call the onClose callback.

Yes. Calling dialogRef.current.showModal() provides built-in backdrop styling, focus trapping, and Escape key dismissal natively across all modern browsers.