DEV Community

Eko S Aryadi
Eko S Aryadi

Posted on Edited on

How to Build a Reusable Modal Component in React with Tailwind CSS

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:

  1. Setting up a New React Project with Vite
  2. Installing and Configuring Tailwind CSS
  3. Configuring vite.config.js
  4. Importing Tailwind CSS Directives
  5. Organizing the Project Folder Structure
  6. Building the Reusable Modal Component
  7. Implementing the Modal in the Main App
  8. Conclusion

📌 Environment & Versions Used in This Tutorial:

  • Vite: v8.x
  • React: v19.x
  • Tailwind CSS: v4.3.x (using @tailwindcss/vite plugin 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
Enter fullscreen mode Exit fullscreen mode

During the setup, the CLI will prompt you with a few interactive options:

  1. Which linter to use? Choose ESLint for code quality standards.
  2. 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 Yes on Install 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
Enter fullscreen mode Exit fullscreen mode

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()],
});
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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>,
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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 the useModal hook.
  • 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>
  );
};
Enter fullscreen mode Exit fullscreen mode

Info modal component displaying system maintenance notice UI

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

Delete confirmation modal dialog with cancel and confirm buttons

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

Edit profile modal dialog with input fields and action buttons

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;
Enter fullscreen mode Exit fullscreen mode

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.

Here is the layout we've got:
Main application layout with three modal trigger buttons

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)