Skip to content

Modal

A flexible and accessible modal component for displaying dialogs, forms, and overlay content. Built with React and styled with Tailwind CSS with proper focus management and keyboard navigation.

Component Code

import React, { useEffect, useRef } from "react";
import { XMarkIcon } from "@heroicons/react/24/outline";
const Modal = ({
isOpen = false,
onClose,
title,
children,
footer,
size = "md",
closeOnOverlay = true,
closeOnEscape = true,
showCloseButton = true,
className = "",
}) => {
const modalRef = useRef(null);
const previousFocusRef = useRef(null);
useEffect(() => {
if (isOpen) {
// Store the currently focused element
previousFocusRef.current = document.activeElement;
// Focus the modal when it opens
if (modalRef.current) {
modalRef.current.focus();
}
// Prevent body scroll
document.body.style.overflow = "hidden";
} else {
// Restore body scroll
document.body.style.overflow = "unset";
// Restore focus to previously focused element
if (previousFocusRef.current) {
previousFocusRef.current.focus();
}
}
return () => {
document.body.style.overflow = "unset";
};
}, [isOpen]);
useEffect(() => {
const handleEscape = (e) => {
if (closeOnEscape && e.key === "Escape" && isOpen) {
onClose?.();
}
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
}
return () => {
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen, closeOnEscape, onClose]);
const handleOverlayClick = (e) => {
if (closeOnOverlay && e.target === e.currentTarget) {
onClose?.();
}
};
const getSizeStyles = () => {
switch (size) {
case "sm":
return "max-w-md";
case "lg":
return "max-w-2xl";
case "xl":
return "max-w-4xl";
case "full":
return "max-w-full mx-4";
default:
return "max-w-lg";
}
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50 transition-opacity duration-300"
onClick={handleOverlayClick}
aria-labelledby={title ? "modal-title" : undefined}
aria-modal="true"
role="dialog"
>
<div
ref={modalRef}
className={`
${getSizeStyles()}
w-full bg-white rounded-lg shadow-xl transform transition-all duration-300 scale-100 opacity-100
${className}
`}
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
{(title || showCloseButton) && (
<div className="flex items-center justify-between p-6 border-b border-gray-200">
{title && (
<h2
id="modal-title"
className="text-lg font-semibold text-gray-900"
>
{title}
</h2>
)}
{showCloseButton && (
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors duration-200"
aria-label="Close modal"
>
<XMarkIcon className="w-5 h-5" />
</button>
)}
</div>
)}
{/* Content */}
<div className="p-6">{children}</div>
{/* Footer */}
{footer && (
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-50">
{footer}
</div>
)}
</div>
</div>
);
};
export default Modal;

Usage Example

import React, { useState } from "react";
import Modal from "./Modal";
const App = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [confirmModal, setConfirmModal] = useState(false);
const [formModal, setFormModal] = useState(false);
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<h1 className="text-3xl font-bold text-gray-900">Modal Examples</h1>
{/* Trigger Buttons */}
<div className="flex flex-wrap gap-3">
<button
onClick={() => setIsModalOpen(true)}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
Open Basic Modal
</button>
<button
onClick={() => setConfirmModal(true)}
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
Open Confirmation Modal
</button>
<button
onClick={() => setFormModal(true)}
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600"
>
Open Form Modal
</button>
</div>
{/* Basic Modal */}
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Basic Modal"
>
<p className="text-gray-600">
This is a basic modal with a title and close button. You can put any
content here.
</p>
</Modal>
{/* Confirmation Modal */}
<Modal
isOpen={confirmModal}
onClose={() => setConfirmModal(false)}
title="Confirm Action"
size="sm"
footer={
<>
<button
onClick={() => setConfirmModal(false)}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
>
Cancel
</button>
<button
onClick={() => {
alert("Confirmed!");
setConfirmModal(false);
}}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Delete
</button>
</>
}
>
<p className="text-gray-600">
Are you sure you want to delete this item? This action cannot be
undone.
</p>
</Modal>
{/* Form Modal */}
<Modal
isOpen={formModal}
onClose={() => setFormModal(false)}
title="Contact Form"
size="lg"
footer={
<>
<button
onClick={() => setFormModal(false)}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
>
Cancel
</button>
<button
type="submit"
form="contact-form"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Send Message
</button>
</>
}
>
<form id="contact-form" className="space-y-4">
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-gray-700 mb-1"
>
Name
</label>
<input
type="text"
id="name"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Your name"
/>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
>
Email
</label>
<input
type="email"
id="email"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="your@email.com"
/>
</div>
<div>
<label
htmlFor="message"
className="block text-sm font-medium text-gray-700 mb-1"
>
Message
</label>
<textarea
id="message"
rows="4"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Your message..."
/>
</div>
</form>
</Modal>
</div>
);
};
export default App;

Props

PropTypeDefaultDescription
isOpenbooleanfalseWhether the modal is visible
onClosefunctionundefinedFunction called when modal should close
titlestringundefinedModal title (optional)
childrenReactNoderequiredModal content
footerReactNodeundefinedFooter content (usually buttons)
size'sm' | 'md' | 'lg' | 'xl' | 'full''md'Modal size
closeOnOverlaybooleantrueClose when clicking outside modal
closeOnEscapebooleantrueClose when pressing Escape key
showCloseButtonbooleantrueShow X button in header
classNamestring''Additional CSS classes

Features

  • Accessible: Proper focus management and ARIA attributes
  • Keyboard Navigation: Escape key support and focus trapping
  • Multiple Sizes: From small dialogs to full-screen modals
  • Overlay Control: Configurable overlay click behavior
  • Flexible Layout: Header, content, and footer sections
  • Smooth Animations: CSS transitions for show/hide
  • Body Scroll Lock: Prevents background scrolling when open
  • Small (sm): 448px max width - perfect for confirmations
  • Medium (md): 512px max width - default size for most content
  • Large (lg): 672px max width - for forms and detailed content
  • Extra Large (xl): 896px max width - for complex interfaces
  • Full: Full width with margins - for mobile-first designs

Common Patterns

Confirmation Dialog

<Modal
isOpen={showConfirm}
onClose={() => setShowConfirm(false)}
title="Confirm Delete"
size="sm"
footer={
<>
<button
onClick={() => setShowConfirm(false)}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
>
Cancel
</button>
<button
onClick={handleDelete}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Delete
</button>
</>
}
>
<p>
Are you sure you want to delete this item? This action cannot be undone.
</p>
</Modal>

Form Modal

<Modal
isOpen={showForm}
onClose={() => setShowForm(false)}
title="Add New Item"
footer={
<>
<button
type="button"
onClick={() => setShowForm(false)}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
>
Cancel
</button>
<button
type="submit"
form="item-form"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Save
</button>
</>
}
>
<form id="item-form" className="space-y-4">
<input
type="text"
placeholder="Item name"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<textarea
placeholder="Description"
rows="3"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</form>
</Modal>

Simple Content Modal

<Modal
isOpen={showContent}
onClose={() => setShowContent(false)}
title="About This Feature"
showCloseButton={true}
>
<div className="space-y-4">
<p className="text-gray-600">
This feature allows you to manage your content more effectively.
</p>
<ul className="list-disc list-inside text-gray-600 space-y-1">
<li>Create and edit content</li>
<li>Organize with categories</li>
<li>Share with team members</li>
</ul>
</div>
</Modal>

Installation

Install Heroicons for the close button icon:

Terminal window
npm install @heroicons/react

Accessibility Features

  • Focus Management: Automatically focuses modal when opened and restores focus when closed
  • Keyboard Navigation: Escape key closes modal, Tab navigation within modal
  • ARIA Attributes: Proper role="dialog", aria-modal, and aria-labelledby
  • Screen Reader Support: Modal content is properly announced
  • Focus Trapping: Focus stays within modal when open (can be enhanced with additional libraries)

Customization Examples

Different Sizes

// Small confirmation dialog
<Modal size="sm" title="Confirm" isOpen={open} onClose={close}>
<p>Are you sure?</p>
</Modal>
// Large content modal
<Modal size="lg" title="Settings" isOpen={open} onClose={close}>
<div>Detailed settings form...</div>
</Modal>
// Full-width mobile-friendly
<Modal size="full" title="Mobile Form" isOpen={open} onClose={close}>
<div>Mobile-optimized content...</div>
</Modal>

Custom Styling

// Dark theme modal
<Modal
isOpen={open}
onClose={close}
className="bg-gray-800 text-white border border-gray-700"
title="Dark Modal"
>
<p className="text-gray-300">Dark themed content...</p>
</Modal>
// Rounded modal
<Modal
isOpen={open}
onClose={close}
className="rounded-2xl"
title="Rounded Modal"
>
<p>Content with extra rounded corners...</p>
</Modal>
<Modal isOpen={open} onClose={close} showCloseButton={false}>
<div className="text-center">
<h3 className="text-lg font-semibold mb-4">Custom Header</h3>
<p>Modal without the default header section.</p>
<button
onClick={close}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded-lg"
>
Close
</button>
</div>
</Modal>

Best Practices

  1. Use appropriate sizes - Choose modal size based on content complexity
  2. Provide clear actions - Include obvious ways to close or complete the modal
  3. Keep content focused - Modals should have a single, clear purpose
  4. Handle edge cases - Consider what happens when content is very long
  5. Test keyboard navigation - Ensure modal works without a mouse
  6. Mobile considerations - Test on smaller screens and touch devices
  7. Avoid nested modals - Generally avoid opening modals from within modals

Notes

  • Modal automatically prevents body scrolling when open
  • Uses CSS transitions for smooth show/hide animations
  • Includes proper focus management for accessibility
  • Supports all common modal patterns (confirmation, form, content)
  • Easy to customize with Tailwind CSS utilities