- React
- Frontend Security, Taint APIs, and Web Accessibility
- Accessible Modal Dialog with Focus Trapping & Portals
Accessible Modal Dialog with Focus Trapping & Portals
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';34interface ModalProps {5 isOpen: boolean;6 onClose: () => void;7 title: string;8 children: ReactNode;9}1011export function AccessibleModal({ isOpen, onClose, title, children }: ModalProps) {12 const modalRef = useRef<HTMLDivElement>(null);13 const previouslyFocusedElement = useRef<HTMLElement | null>(null);1415 useEffect(() => {16 if (!isOpen) return;1718 // 1. Save currently focused element to restore on close19 previouslyFocusedElement.current = document.activeElement as HTMLElement;2021 // 2. Lock background body scrolling22 const originalOverflow = document.body.style.overflow;23 document.body.style.overflow = 'hidden';2425 // 3. Move focus to modal or its first focusable child26 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 }3435 // Cleanup on close36 return () => {37 document.body.style.overflow = originalOverflow;38 previouslyFocusedElement.current?.focus();39 };40 }, [isOpen]);4142 // 4. Focus Trap: Cycle Tab / Shift+Tab within modal43 const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {44 if (e.key === 'Escape') {45 e.stopPropagation();46 onClose();47 return;48 }4950 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;5556 const firstElement = focusable[0];57 const lastElement = focusable[focusable.length - 1];5859 if (e.shiftKey) {60 // Shift + Tab: If on first element, wrap to last61 if (document.activeElement === firstElement) {62 e.preventDefault();63 lastElement.focus();64 }65 } else {66 // Tab: If on last element, wrap to first67 if (document.activeElement === lastElement) {68 e.preventDefault();69 firstElement.focus();70 }71 }72 }73 };7475 if (!isOpen) return null;7677 return createPortal(78 <div className="modal-backdrop" onClick={onClose}>79 <div80 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 click87 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>9596 <main className="modal-body">{children}</main>97 </div>98 </div>,99 document.body100 );101}
Deep Architectural Explanation
Anatomy of a Production-Ready Accessible Modal
A Modal Dialog is considered accessible only if it satisfies five critical criteria:
- Focus Trap:
- When a user presses
Tabon the last focusable element in the dialog, focus wraps to the first element. - When pressing
Shift + Tabon the first element, focus wraps to the last element. - Focus can NEVER escape into the background webpage while the modal is open.
- When a user presses
- Escape Dismissal:
- Pressing the
Escapekey immediately triggersonClose().
- Pressing the
- Scroll Lock:
- Setting
document.body.style.overflow = "hidden"prevents the background page from scrolling under the modal backdrop on wheel or touch events.
- Setting
- Focus Restoration:
- When the modal unmounts, focus returns to
previouslyFocusedElement.current.
- When the modal unmounts, focus returns to
- 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
Why is aria-modal="true" important?
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.
How do you prevent background scrolling when a modal opens?
Set document.body.style.overflow = "hidden" when the modal mounts, and restore the original overflow style in the cleanup effect when it unmounts.
Why should a modal be rendered into a React Portal?
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.
How does Escape key dismissal work in React modals?
Listen for keydown events with e.key === "Escape" on the modal container or window, stop propagation, and call the onClose callback.
Can you use the native HTML5 <dialog> element instead?
Yes. Calling dialogRef.current.showModal() provides built-in backdrop styling, focus trapping, and Escape key dismissal natively across all modern browsers.
