javascript 69 lines · 9 steps

Building an accessible modal in React

A React modal that traps focus, handles Escape, and restores focus on close using portals and refs.

Explained by highlit
1import { useCallback, useEffect, useRef } from 'react';
2import { createPortal } from 'react-dom';
3 
4const FOCUSABLE = 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
5 
6export default function Modal({ isOpen, onClose, title, children }) {
7 const dialogRef = useRef(null);
8 const previouslyFocused = useRef(null);
9 
10 const handleKeyDown = useCallback((event) => {
11 if (event.key === 'Escape') {
12 onClose();
13 return;
14 }
15 if (event.key !== 'Tab') return;
16 
17 const nodes = dialogRef.current.querySelectorAll(FOCUSABLE);
18 if (nodes.length === 0) return;
19 
20 const first = nodes[0];
21 const last = nodes[nodes.length - 1];
22 
23 if (event.shiftKey && document.activeElement === first) {
24 event.preventDefault();
25 last.focus();
26 } else if (!event.shiftKey && document.activeElement === last) {
27 event.preventDefault();
28 first.focus();
29 }
30 }, [onClose]);
31 
32 useEffect(() => {
33 if (!isOpen) return;
34 
35 previouslyFocused.current = document.activeElement;
36 const first = dialogRef.current.querySelector(FOCUSABLE);
37 (first ?? dialogRef.current).focus();
38 
39 document.body.style.overflow = 'hidden';
40 return () => {
41 document.body.style.overflow = '';
42 previouslyFocused.current?.focus();
43 };
44 }, [isOpen]);
45 
46 if (!isOpen) return null;
47 
48 return createPortal(
49 <div className="modal-overlay" onMouseDown={onClose}>
50 <div
51 ref={dialogRef}
52 className="modal-dialog"
53 role="dialog"
54 aria-modal="true"
55 aria-label={title}
56 tabIndex={-1}
57 onKeyDown={handleKeyDown}
58 onMouseDown={(e) => e.stopPropagation()}
59 >
60 <header className="modal-header">
61 <h2>{title}</h2>
62 <button type="button" aria-label="Close" onClick={onClose}>×</button>
63 </header>
64 <div className="modal-body">{children}</div>
65 </div>
66 </div>,
67 document.body,
68 );
69}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1A focus trap works by cycling Tab from the last focusable element back to the first, and vice versa with Shift+Tab.
  2. 2Saving the previously focused element lets you restore focus when the modal closes, keeping keyboard users oriented.
  3. 3Rendering into document.body via a portal escapes parent stacking contexts while staying inside React's tree.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Building an accessible modal in React — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code