Building an Accessible Modal Dialog in React: Focus Trapping and Keyboard Navigation
Learn how to build a fully accessible, custom modal dialog in React from scratch using TypeScript, complete with focus trapping, ARIA attributes, and keyboard navigation.
Building an Accessible Modal Dialog in React: Focus Trapping and Keyboard Navigation
When building user interfaces, modal dialogs are among the most common yet frequently mishandled components. A poorly constructed modal is a barrier for keyboard users, screen reader users, and individuals with cognitive or motor disabilities.
While heavy component libraries (like Radix, Headless UI, or Chakra) provide accessible modals out of the box, understanding how to build one from scratch in React is crucial for mastering web accessibility (a11y).
In this guide, we will build a production-ready, accessible modal dialog in React and TypeScript. We will tackle the four pillars of modal accessibility:
- Semantic ARIA Attributes (
aria-modal,aria-labelledby,aria-describedby) - Focus Management & Trapping (keeping keyboard focus inside the modal)
- Keyboard Navigation (handling the
Escapekey) - Focus Restoration (returning focus to the trigger element on close)
1. The Anatomy of an Accessible Modal
Before writing code, let’s look at the HTML structure required by screen readers to understand a modal dialog. A proper modal needs:
- A wrapper with
role="dialog"andaria-modal="true". - An explicit label via
aria-labelledbypointing to the modal title. - An optional description via
aria-describedby. - A backdrop that blocks interaction with the background content.
Here is our TypeScript interface and component shell:
import React, { useEffect, useRef } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
}) => {
if (!isOpen) return null;
return (
<div className="modal-backdrop">
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-content"
>
<h2 id="modal-title">{title}</h2>
<div>{children}</div>
<button onClick={onClose}>Close</button>
</div>
</div>
);
};
2. Restoring Focus on Close
When a user opens a modal, their current focus point shifts into the modal. When they close it, their focus must return to the element that triggered the modal in the first place.
To achieve this, we can use the document.activeElement API via a useRef hook:
import React, { useEffect, useRef } from 'react';
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
}) => {
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// Store the element that currently has focus
previousFocusRef.current = document.activeElement as HTMLElement;
} else {
// Restore focus when modal closes
if (previousFocusRef.current) {
previousFocusRef.current.focus();
}
}
}, [isOpen]);
if (!isOpen) return null;
// ... render modal
};
3. Implementing Focus Trapping
By default, a keyboard user pressing the Tab key will cycle through focusable elements on the page. Once they reach the end of the modal, their focus would naturally bleed out into the background page content (which should be inert).
To prevent this, we must trap focus inside the modal. When the user tabs forward from the last focusable element, we loop focus back to the first element. When they shift-tab backward from the first element, we loop focus to the last element.
Let’s write a robust focus-trapping effect:
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const modalElement = modalRef.current;
if (!modalElement) return;
// Find all focusable elements inside the modal
const focusableElements = modalElement.querySelectorAll<
HTMLElement
>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Automatically focus the first element when modal opens
firstElement?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
if (e.key === 'Tab') {
if (e.shiftKey) {
// If shift + tab and focus is on first element, move to last
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
// If tab and focus is on last element, move to first
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
4. Putting It All Together
Here is the complete, cohesive Modal component featuring TypeScript, focus restoration, focus trapping, and Escape key handling.
import React, { useEffect, useRef } from 'react';
import './Modal.css';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement;
const modalElement = modalRef.current;
if (!modalElement) return;
const focusableElements = modalElement.querySelectorAll<
HTMLElement
>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
firstElement?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
// Prevent scrolling on the body when modal is open
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'unset';
if (previousFocusRef.current) {
previousFocusRef.current.focus();
}
};
}
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal-content"
onClick={(e) => e.stopPropagation()} // Prevent close on modal body click
>
<header className="modal-header">
<h2 id="modal-title">{title}</h2>
<button
onClick={onClose}
aria-label="Close modal"
className="modal-close-btn"
>
×
</button>
</header>
<div className="modal-body">
{children}
</div>
</div>
</div>
);
};
5. Styling Considerations for Accessibility
Accessibility isn’t just about JavaScript; CSS plays a massive role in visual feedback. Ensure your modal has:
- High contrast between the backdrop and the modal container.
- Visible focus rings on buttons and interactive elements (
outline: 2px solid). Never remove focus outlines usingoutline: noneunless you provide a custom, high-contrast replacement.
.modal-backdrop {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 2rem;
border-radius: 8px;
max-width: 500px;
width: 100%;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
/* Never remove focus outlines entirely! */
button:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 2px;
}
Conclusion
Building an accessible modal dialog from scratch requires careful attention to detail, but the concepts are straightforward once broken down:
Accessibility Checklist:
- Use
role="dialog"andaria-modal="true".- Link your title using
aria-labelledby.- Trap keyboard focus so it cannot escape into the background.
- Listen for the
Escapekey to close the dialog.- Restore focus to the trigger element when the modal unmounts.
By implementing these patterns directly into your React applications, you ensure that your web experiences are inclusive and welcoming to all users.