A few months ago, I ran into some frustrating challenges when trying to build a reusable modal component using ReactJS. Of course, there are many good modal libraries out there. But, what if you don't want to overengineer your project and keep the dependencies minimal?
If you’ve ever found yourself in the same boat, let me walk you through how I solved it.
📦 Source Code: You can find the complete source code for this project in this GitHub Repository.
Prerequisites
Before following this tutorial, make sure you have:
- Node.js (v22.0 or later) installed on your machine.
- A code editor installed (e.g., VS Code).
- Basic knowledge of React (Hooks, State, and Props) and JavaScript (ES6+).
- Familiarity with running basic commands in your terminal or VS Code integrated terminal.
What We'll Cover
In this guide, we will walk through the entire process step-by-step:
- Setting up a New React Project with Vite
- Installing and Configuring Tailwind CSS
- Configuring
vite.config.js - Importing Tailwind CSS Directives
- Organizing the Project Folder Structure
- Building the Reusable Modal Component
- Implementing the Modal in the Main App
- Conclusion
📌 Environment & Versions Used in This Tutorial:
- Vite: v8.x
- React: v19.x
- Tailwind CSS: v4.3.x (using
@tailwindcss/viteplugin for Vite)Note: Tailwind CSS syntax and plugin setups often change between major versions (e.g., v3 vs v4). Make sure you are using the corresponding version or refer to the official Tailwind documentation if you encounter setup issues.
Setting up a New React Project with Vite
First create a new project using this command:
npm create vite@latest tailwind-react-modal -- --template react
During the setup, the CLI will prompt you with a few interactive options:
- Which linter to use? Choose ESLint for code quality standards.
- Install with npm and start now? Choose Yes to let the CLI automatically install dependencies and launch the development server for you.
Note: If you choose
YesonInstall with npm and start now?question, make sure you're in the root project when restarting the server again.
Once the process finishes, your terminal will display the local development URL, typically at http://localhost:5173/.
Installing and Configuring Tailwind CSS
Run this command in your terminal to install official tailwind v4 and its vite plugin:
npm install tailwindcss @tailwindcss/vite
Configuring vite.config.js
Add @tailwindcss/vite plugin in your vite.config.js. So, your file will look like this:
/* vite.config.js */
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
});
Importing Tailwind CSS Directives
Open your main css file of the project in src/index.css. Then, delete everything there.
After that import tailwindcss there:
/* src/index.css */
@import 'tailwindcss';
Different from tailwindcss v3, in tailwindcss v4, we no longer need to import @tailwind base, @tailwind components, and @tailwind utilities.
The last step for this setup is to make sure src/index.css css is being called in src/main.jsx. The file should look like this:
/* src/main.jsx */
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
);
Organizing the Project Folder Structure
To organize this project, we'll use this folder structure:
src
├── App.jsx
├── assets
│ ├── confirmationModal.jpg
│ ├── editProfileModal.jpg
│ ├── hero.png
│ ├── infoModal.jpg
│ ├── modalTriggers.jpg
│ ├── react.svg
│ └── vite.svg
├── components
│ ├── common
│ │ └── Modal.jsx
│ └── modals
│ ├── DeleteModal.jsx
│ ├── EditProfileModal.jsx
│ └── InfoModal.jsx
├── hooks
│ └── useModal.js
├── index.css
└── main.jsx
I'll delete the App.css, but you can keep it if you want.
We'll create three modals for this sample project: info modal, delete modal and edit profile modal. So, we can see how our reusable modal being used in three different scenarios.
Everything we need to build the project is in place. Now, it's time for the main course.
Building the Reusable Modal Component
Modal Custom Hook
We'll start our modal wiring by creating a custom hook. You might wonder: Do we really need a custom hook for something as simple as a modal? Isn't that overkill?
The hook will keep our main modal component stay clean. It also makes our modal modular and easier to manage.
Create hooks folder inside src and create a new file called useModal.js. This is how our custom hook will look like:
/* src/hooks/useModal.js */
import { useState, useCallback } from 'react';
// Simple hook to toggle modal state without repeating useState everywhere
export const useModal = (initialState = false) => {
const [isOpen, setIsOpen] = useState(initialState);
const openModal = useCallback(() => setIsOpen(true), []);
const closeModal = useCallback(() => setIsOpen(false), []);
const toggleModal = useCallback(() => setIsOpen((prev) => !prev), []);
return { isOpen, openModal, closeModal, toggleModal };
};
We use useState to check whether our modal is open or closed, defaulting to false.
Then, we use useCallback to set the modal state. This prevents unnecessary re-renders every time a component calls the hook.
Our hook will return the modal state and our functions.
By wrapping the state inside our useCallback, we can manage multiple modals on a single page. Avoiding state collisions in each different modal.
Reusable Modal Base Component
After our hook is ready, the next thing we should take care of is the reusable modal component.
Create a new folder inside src, name it components. Inside the components folder, create a new folder called common. This is where our reusable modal component, Modal.jsx, lives.
/* src/components/common/Modal.jsx */
import { useEffect } from 'react';
export const Modal = ({
isOpen,
onClose,
title,
children,
footer,
maxWidth = 'max-w-lg', // flexible width so we can reuse it for different sizes
}) => {
// Close on ESC and lock body scroll when the modal is up
useEffect(() => {
const handleKeyDown = (event) => {
if (event.key === 'Escape' && isOpen) {
onClose();
}
};
if (isOpen) {
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown);
}
return () => {
document.body.style.overflow = 'unset';
window.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
className='fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm transition-opacity'
aria-hidden='true'>
{/* Click outside to dismiss */}
<div
className='absolute inset-0'
onClick={onClose}
aria-label='Close modal backdrop'
/>
{/* Main dialog box */}
<div
role='dialog'
aria-modal='true'
aria-labelledby='modal-title'
className={`relative w-full ${maxWidth} bg-white rounded-2xl shadow-2xl border border-slate-100 overflow-hidden transform transition-all z-10`}>
{/* Header section */}
<div className='flex items-center justify-between px-6 py-4 border-b border-slate-100'>
<h3 id='modal-title' className='text-lg font-semibold text-slate-800'>
{title}
</h3>
<button
onClick={onClose}
className='p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors cursor-pointer'
aria-label='Close modal'>
<svg
className='w-5 h-5'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M6 18L18 6M6 6l12 12'
/>
</svg>
</button>
</div>
{/* Content area */}
<div className='px-6 py-4 text-slate-600 text-sm leading-relaxed'>
{children}
</div>
{/* Optional footer for actions */}
{footer && (
<div className='flex items-center justify-end gap-3 px-6 py-4 bg-slate-50 border-t border-slate-100'>
{footer}
</div>
)}
</div>
</div>
);
};
Our reusable modal will be able to receive the following props:
-
isOpen: To check the modal state. -
onClose: To trigger the close modal function. It'll be handled by theuseModalhook. -
title: To set the modal title displayed in the title bar. -
footer: This is optional. If the modal is just for notice and information display, we might not need it. If the modal needs action buttons for cancellation or acceptance, we can place those buttons here. -
maxWidth: To set the max-width of the modal, in case we need it.
We use !isOpen to return null early, so the modal stays hidden when the app first loads. Once a user triggers an action, the state flips to open and the modal pops up.
To handle side effects like managing the keydown event listener and locking background scrolling (document.body), we’ll lean on useEffect with isOpen and onClose as dependencies.
Another important thing to note is we employ role="dialog", aria-modal="true", using esc keyboard key to close modal, and lock body scroll when one of the modals is visible. What we do here is to improve user accessibility.
Use Cases
To prove that our reusable modal works, we'll use it in three different scenarios: info modal, delete modal and edit profile modal.
We'll wire each individual modal from our reusable modal component. Later, we'll call it in our App.jsx.
All individual modals will reside in src/components/modals/.
All individual modals will import Modal from ../common/Modal. Then, we'll use and fill the reusable modal properties based on our needs.
Info Modal
Our first type of modal is an info modal. Here, we'll use it for informational purposes only. Therefore, we don't need to add action buttons in the footer.
This is how InfoModal.jsx will look like:
/* src/components/modals/InfoModal.jsx */
import { Modal } from '../common/Modal';
export const InfoModal = ({ isOpen, onClose }) => {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title='System Maintenance Notice'
maxWidth='max-w-md'>
<p>
Our platform will undergo scheduled maintenance this Sunday from 2:00 AM
to 4:00 AM UTC. Some services may be temporarily unavailable during this
window.
</p>
</Modal>
);
};
Delete Modal
Different from the info modal, in the delete modal, we'll use footer to display action buttons for the deletion process which asks for the user permission, namely: Cancel and Confirm Delete.
/* src/components/modals/DeleteModal.jsx */
import { Modal } from '../common/Modal';
export const DeleteModal = ({ isOpen, onClose, onConfirm }) => {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title='Delete Account'
maxWidth='max-w-md'
footer={
<>
<button
onClick={onClose}
className='px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-800 transition-colors cursor-pointer'>
Cancel
</button>
<button
onClick={onConfirm}
className='px-4 py-2 text-sm font-medium bg-rose-600 text-white rounded-lg hover:bg-rose-700 transition-colors cursor-pointer'>
Confirm Delete
</button>
</>
}>
<p>
Are you sure you want to delete your account? All of your data will be
permanently removed. This action cannot be undone.
</p>
</Modal>
);
};
Edit Profile Modal
The last modal is the edit profile modal. It'll also use the footer to display different action buttons: Cancel and Save Changes.
/* src/components/modals/EditProfileModal.jsx */
import { Modal } from '../common/Modal';
export const EditProfileModal = ({ isOpen, onClose, onSubmit }) => {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title='Edit Profile Information'
footer={
<>
<button
onClick={onClose}
className='px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 rounded-lg transition-colors cursor-pointer'>
Cancel
</button>
<button
onClick={onSubmit}
className='px-4 py-2 text-sm font-medium bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors cursor-pointer'>
Save Changes
</button>
</>
}>
<div className='space-y-4'>
<div>
<label className='block text-xs font-semibold uppercase tracking-wider text-slate-500 mb-1'>
Full Name
</label>
<input
type='text'
defaultValue='John Doe'
className='w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500'
/>
</div>
<div>
<label className='block text-xs font-semibold uppercase tracking-wider text-slate-500 mb-1'>
Email Address
</label>
<input
type='email'
defaultValue='john@example.com'
className='w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500'
/>
</div>
</div>
</Modal>
);
};
Implementing the Modal in the Main App
We've created everything we need. Now, it's time to see our reusable modal in action.
Edit src/App.jsx and delete everything in the App.jsx boilerplate. Then, make it to look like this:
/* src/App.jsx */
import { useModal } from './hooks/useModal';
import { InfoModal } from './components/modals/InfoModal';
import { DeleteModal } from './components/modals/DeleteModal';
import { EditProfileModal } from './components/modals/EditProfileModal';
export const App = () => {
// Hook up states for each modal we need on this page
const infoModal = useModal(false);
const deleteModal = useModal(false);
const formModal = useModal(false);
const handleDeleteConfirm = () => {
alert('Account deleted successfully!');
deleteModal.closeModal();
};
const handleFormSubmit = () => {
alert('Profile changes saved successfully!');
formModal.closeModal();
};
return (
<div className='min-h-screen bg-slate-50 flex flex-col items-center justify-center p-6 gap-4'>
<h1 className='text-2xl font-bold text-slate-800 mb-2'>
Reusable Tailwind Modal
</h1>
<div className='flex gap-4'>
<button
onClick={infoModal.openModal}
className='px-4 py-2 bg-emerald-600 text-white font-medium rounded-lg shadow-sm hover:bg-emerald-700 transition-colors cursor-pointer'>
Open Info Modal
</button>
<button
onClick={deleteModal.openModal}
className='px-4 py-2 bg-indigo-600 text-white font-medium rounded-lg shadow-sm hover:bg-indigo-700 transition-colors cursor-pointer'>
Open Confirmation Modal
</button>
<button
onClick={formModal.openModal}
className='px-4 py-2 bg-white text-slate-700 border border-slate-300 font-medium rounded-lg shadow-sm hover:bg-slate-50 transition-colors cursor-pointer'>
Open Form Modal
</button>
</div>
{/* Render modals cleanly outside the main layout flow */}
<InfoModal isOpen={infoModal.isOpen} onClose={infoModal.closeModal} />
<DeleteModal
isOpen={deleteModal.isOpen}
onClose={deleteModal.closeModal}
onConfirm={handleDeleteConfirm}
/>
<EditProfileModal
isOpen={formModal.isOpen}
onClose={formModal.closeModal}
onSubmit={handleFormSubmit}
/>
</div>
);
};
export default App;
The first step we import our useModal hook and every single modal we've created: InfoModal, DeleteModal and EditProfileModal.
Since we'll use three different modals, we'll create three different variables to call our useModal hook.
Some of you might be wondering why we need three. It's because each variable will hold the state for one of the three different modals we're demonstrating here.
Notice how we call the isOpen and closeModal methods from each variable.
For the DeleteModal, we will create handleDeleteConfirm where we trigger an alert to confirm our delete modal is executed successfully.
We also create handleFormSubmit function for the EditProfileModal to handle profile changes.
The handleDeleteConfirm and handleFormSubmit will go to their respective modals.
For the last step, we need to make something to trigger the modals. Here, we'll use three different buttons with their own label.
Test it to see our reusable modal in action by clicking each trigger button.
Congratulations! All of our reusable modals will work seamlessly. This reusable modal component can be used anywhere in our application.
Conclusion
Many good modal libraries exist, and we can pick whatever we want from that list.
However, for those who prefer minimal project dependencies or want to avoid overengineering, a simple and modular approach like what we just did is a solid alternative.
Using just a custom hook, useModal.js, and basic react knowledge, we can easily create a reusable modal component that works seamlessly anywhere in our application.




Top comments (0)