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
Prop | Type | Default | Description |
---|---|---|---|
isOpen | boolean | false | Whether the modal is visible |
onClose | function | undefined | Function called when modal should close |
title | string | undefined | Modal title (optional) |
children | ReactNode | required | Modal content |
footer | ReactNode | undefined | Footer content (usually buttons) |
size | 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'md' | Modal size |
closeOnOverlay | boolean | true | Close when clicking outside modal |
closeOnEscape | boolean | true | Close when pressing Escape key |
showCloseButton | boolean | true | Show X button in header |
className | string | '' | 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
Modal Sizes
- 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:
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
, andaria-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 Without Header
<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
- Use appropriate sizes - Choose modal size based on content complexity
- Provide clear actions - Include obvious ways to close or complete the modal
- Keep content focused - Modals should have a single, clear purpose
- Handle edge cases - Consider what happens when content is very long
- Test keyboard navigation - Ensure modal works without a mouse
- Mobile considerations - Test on smaller screens and touch devices
- 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