Modals (dialog boxes) are everywhere in modern web apps: confirmation dialogs, login forms, image previews, and more. But hardcoding a new modal for every use case leads to duplicate code, inconsistent behavior, and maintenance headaches. The solution? Build a reusable modal component in React. Developers can drop it anywhere with confidence. In this tutorial, I’ll show you how to create a reusable modal component in React with TypeScript that handles open/close state, accessibility (focus trapping, ESC key), animations, and portal rendering—all with full type safety.
Why TypeScript Matters for Reusable Components
When building a component that multiple team members or projects will use, TypeScript becomes invaluable. It catches prop errors at compile time, provides autocomplete in IDEs, and documents the API automatically. By learning to create a reusable modal component in React TypeScript style, you’ll ship fewer bugs and improve developer experience across your codebase.
What You’ll Build
A <Modal> component that:
-
Accepts
isOpen,onClose,title, andchildrenas props. -
Renders into a React portal (to break out of parent CSS stacking contexts).
-
Closes on ESC key press and backdrop click.
-
Traps focus inside the modal for keyboard users.
-
Supports optional custom styling and animations.
Step 1: Set Up the TypeScript Props Interface
First, define the shape of your modal’s props. Create a file Modal.tsx:
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
closeOnBackdropClick?: boolean; // optional, defaults to true
}
const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
closeOnBackdropClick = true,
}) => {
// Implementation goes here
};
This interface ensures that anyone using the modal provides the required props with correct types. The closeOnBackdropClick is optional, demonstrating how to create a reusable modal component React TypeScript patterns use optional props with defaults.
Step 2: Add Portal Rendering
Modals should not be constrained by parent overflow: hidden or z-index contexts. React Portals let you render a component’s children into a DOM node outside the parent component hierarchy.
Add this inside your Modal component:
useEffect(() => {
if (!isOpen) return;
// Prevent body scroll when modal is open
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
if (!isOpen) return null;
return ReactDOM.createPortal(
<div className="modal-overlay" onClick={() => closeOnBackdropClick && onClose()}>
<div className="modal-container" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>{title}</h2>
<button className="modal-close-btn" onClick={onClose}>×</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>,
document.getElementById('modal-root') || document.body
);
Note: You’ll need a <div id="modal-root"></div> in your index.html (or create it dynamically).
Step 3: Handle Keyboard Accessibility (ESC Key)
A modal that doesn’t close with the Escape key frustrates users. Add this useEffect:
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
Now your modal responds to ESC – a core requirement for creating a reusable modal component React TypeScript that follows web accessibility guidelines (WCAG).
Step 4: Focus Trapping (Advanced but Essential)
When a modal opens, focus should move inside the modal and not leave until it closes. For a production-ready version, add:
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen && modalRef.current) {
const focusableElements = modalRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements.length) {
(focusableElements[0] as HTMLElement).focus();
}
}
}, [isOpen]);
Then attach ref={modalRef} to the modal container div. For a complete focus trap library, see the external resource: React Focus Trap on GitHub – but the manual approach above works for simple cases.
Step 5: Add Styling and Animations
Create a CSS file (or CSS-in-JS) for your modal:
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
.modal-container {
background: white;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
animation: slideUp 0.2s ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(20px); } to { transform: translateY(0); } }
Using Your Reusable Modal Component
Now any React component can use the modal with just a few lines:
import { useState } from 'react';
import Modal from './Modal';
function MyApp() {
const [modalOpen, setModalOpen] = useState(false);
return (
<div>
<button onClick={() => setModalOpen(true)}>Open Modal</button>
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title="Example Modal"
>
<p>This is reusable content.</p>
<button onClick={() => setModalOpen(false)}>Close</button>
</Modal>
</div>
);
}
Advanced Patterns: Customizing Animation and Size
To make your component even more reusable, extend the props interface with optional size ('sm' | 'md' | 'lg') and showCloseButton. For a deep dive into advanced TypeScript patterns in React, read our internal guide on polymorphic components with TypeScript.
Common Pitfalls and Solutions
| Problem | Fix |
|---|---|
| Modal renders behind other elements | Set z-index: 9999 on overlay, ensure modal-root is high in DOM tree. |
| Click inside modal closes it | Add e.stopPropagation() on the modal container’s click handler (already done above). |
Type errors on children |
Use React.ReactNode – it covers everything React can render. |
| SSR issues with portals | Only call createPortal on the client side (use useEffect + state flag). |
Testing Your Modal
Write a simple test using React Testing Library:
import { render, screen, fireEvent } from '@testing-library/react';
import Modal from './Modal';
test('calls onClose when backdrop is clicked', () => {
const handleClose = jest.fn();
render(
<Modal isOpen={true} onClose={handleClose} title="Test">
Content
</Modal>
);
fireEvent.click(screen.getByTestId('overlay'));
expect(handleClose).toHaveBeenCalled();
});
Conclusion
You’ve just learned creating a reusable modal component in React and TypeScript that is accessible, portable, and type-safe. This pattern can be extended to dialogs, drawers, tooltips, and more. The key takeaways: use portals, trap focus, listen for ESC, and define clear prop interfaces.
For more reusable component patterns, explore our internal collection of React component recipes. If you have questions or need help adapting this modal to your specific design system, feel free to contact our development team. Happy coding!
