Overview
The Modal component creates an accessible dialog overlay with support for multiple sizes, keyboard navigation (ESC to close), overlay click handling, and automatic body scroll locking. Built with React portals for proper rendering outside the DOM hierarchy.
TypeScript Types
ModalProps
interface ModalProps extends HTMLAttributes < HTMLDivElement > {
isOpen : boolean ;
onClose : () => void ;
size ?: 'sm' | 'md' | 'lg' | 'xl' | 'full' ;
closeOnOverlayClick ?: boolean ;
closeOnEsc ?: boolean ;
}
Sub-component Props
interface ModalHeaderProps extends HTMLAttributes < HTMLDivElement > {
onClose ?: () => void ;
showCloseButton ?: boolean ;
}
interface ModalBodyProps extends HTMLAttributes < HTMLDivElement > {}
interface ModalFooterProps extends HTMLAttributes < HTMLDivElement > {}
Modal Component
Controls whether the modal is visible. When false, the modal is not rendered.
Callback function called when the modal should close. Called when:
User presses ESC key (if closeOnEsc is true)
User clicks overlay background (if closeOnOverlayClick is true)
User clicks close button in ModalHeader
size
'sm' | 'md' | 'lg' | 'xl' | 'full'
default: "md"
The maximum width of the modal:
sm - Small (max-w-sm / 384px)
md - Medium (max-w-md / 448px)
lg - Large (max-w-lg / 512px)
xl - Extra Large (max-w-xl / 576px)
full - Full (max-w-[90vw] max-h-[90vh])
When true, clicking the dark overlay background closes the modal.
When true, pressing the ESC key closes the modal.
Additional CSS classes to apply to the modal content container.
Sub-components
Header section with title and optional close button.
Callback for the close button. Required if showCloseButton is true.
When true, displays an X icon button to close the modal.
< ModalHeader onClose = { handleClose } >
Modal Title
</ ModalHeader >
ModalBody
Main content section with:
Horizontal padding: px-6
Vertical padding: py-5
Max height: max-h-[60vh]
Vertical scroll: overflow-y-auto
< ModalBody >
< p > Modal content goes here. </ p >
</ ModalBody >
Footer section for action buttons with:
Top border: border-t border-border
Muted background: bg-muted/30
Flex layout: flex items-center justify-end gap-3
Padding: px-6 py-4
< ModalFooter >
< Button variant = "ghost" onClick = { handleClose } > Cancel </ Button >
< Button variant = "primary" onClick = { handleConfirm } > Confirm </ Button >
</ ModalFooter >
Basic Usage
import { Modal , ModalHeader , ModalBody , ModalFooter } from '@/components/Modal' ;
import { Button } from '@/components/Button' ;
import { useState } from 'react' ;
function ModalExample () {
const [ isOpen , setIsOpen ] = useState ( false );
return (
<>
< Button onClick = { () => setIsOpen ( true ) } >
Open Modal
</ Button >
< Modal isOpen = { isOpen } onClose = { () => setIsOpen ( false ) } >
< ModalHeader onClose = { () => setIsOpen ( false ) } >
Modal Title
</ ModalHeader >
< ModalBody >
< p > This is the modal content. </ p >
</ ModalBody >
< ModalFooter >
< Button variant = "ghost" onClick = { () => setIsOpen ( false ) } >
Cancel
</ Button >
< Button variant = "primary" onClick = { () => setIsOpen ( false ) } >
Confirm
</ Button >
</ ModalFooter >
</ Modal >
</>
);
}
Size Examples
< Modal isOpen = { isOpen } onClose = { handleClose } size = "sm" >
< ModalHeader onClose = { handleClose } >
Small Modal
</ ModalHeader >
< ModalBody >
< p > Ideal for quick confirmations or short messages. </ p >
</ ModalBody >
< ModalFooter >
< Button size = "sm" variant = "ghost" onClick = { handleClose } >
Cancel
</ Button >
< Button size = "sm" variant = "primary" onClick = { handleClose } >
Accept
</ Button >
</ ModalFooter >
</ Modal >
Max width: 384px (max-w-sm) < Modal isOpen = { isOpen } onClose = { handleClose } size = "md" >
< ModalHeader onClose = { handleClose } >
Medium Modal
</ ModalHeader >
< ModalBody >
< p > Perfect for simple forms or moderate content. </ p >
< Input label = "Example Field" placeholder = "Type here..." />
</ ModalBody >
< ModalFooter >
< Button variant = "ghost" onClick = { handleClose } >
Cancel
</ Button >
< Button variant = "primary" onClick = { handleClose } >
Confirm
</ Button >
</ ModalFooter >
</ Modal >
Max width: 448px (max-w-md) < Modal isOpen = { isOpen } onClose = { handleClose } size = "lg" >
< ModalHeader onClose = { handleClose } >
Large Modal
</ ModalHeader >
< ModalBody >
< p > Great for complex forms or extensive content. </ p >
< div className = "grid grid-cols-2 gap-4" >
< Input label = "First Name" placeholder = "John" />
< Input label = "Last Name" placeholder = "Doe" />
</ div >
< Input label = "Email" type = "email" placeholder = "[email protected] " />
</ ModalBody >
< ModalFooter >
< Button variant = "ghost" onClick = { handleClose } >
Cancel
</ Button >
< Button variant = "accent" onClick = { handleClose } >
Submit
</ Button >
</ ModalFooter >
</ Modal >
Max width: 512px (max-w-lg) < Modal isOpen = { isOpen } onClose = { handleClose } size = "full" >
< ModalHeader onClose = { handleClose } >
Full Screen Modal
</ ModalHeader >
< ModalBody >
< p > Takes up most of the viewport for maximum content space. </ p >
</ ModalBody >
< ModalFooter >
< Button variant = "ghost" onClick = { handleClose } > Close </ Button >
</ ModalFooter >
</ Modal >
Max width: 90vw, Max height: 90vh
import { Modal , ModalHeader , ModalBody , ModalFooter } from '@/components/Modal' ;
import { Input } from '@/components/Input' ;
import { Button } from '@/components/Button' ;
import { useState } from 'react' ;
function ContactFormModal () {
const [ isOpen , setIsOpen ] = useState ( false );
const [ formData , setFormData ] = useState ({
firstName: '' ,
lastName: '' ,
email: '' ,
message: ''
});
const handleSubmit = () => {
console . log ( 'Form submitted:' , formData );
setIsOpen ( false );
};
return (
<>
< Button onClick = { () => setIsOpen ( true ) } >
Contact Us
</ Button >
< Modal
isOpen = { isOpen }
onClose = { () => setIsOpen ( false ) }
size = "lg"
>
< ModalHeader onClose = { () => setIsOpen ( false ) } >
Contact Form
</ ModalHeader >
< ModalBody >
< div className = "space-y-4" >
< div className = "grid grid-cols-2 gap-4" >
< Input
label = "First Name"
placeholder = "John"
value = { formData . firstName }
onChange = { ( e ) => setFormData ({ ... formData , firstName: e . target . value }) }
/>
< Input
label = "Last Name"
placeholder = "Doe"
value = { formData . lastName }
onChange = { ( e ) => setFormData ({ ... formData , lastName: e . target . value }) }
/>
</ div >
< Input
label = "Email"
type = "email"
placeholder = "[email protected] "
value = { formData . email }
onChange = { ( e ) => setFormData ({ ... formData , email: e . target . value }) }
/>
< Input
label = "Message"
placeholder = "Your message..."
value = { formData . message }
onChange = { ( e ) => setFormData ({ ... formData , message: e . target . value }) }
/>
</ div >
</ ModalBody >
< ModalFooter >
< Button variant = "ghost" onClick = { () => setIsOpen ( false ) } >
Cancel
</ Button >
< Button variant = "primary" onClick = { handleSubmit } >
Send Message
</ Button >
</ ModalFooter >
</ Modal >
</>
);
}
Confirmation Modal
function DeleteConfirmation () {
const [ isOpen , setIsOpen ] = useState ( false );
const handleDelete = () => {
console . log ( 'Item deleted' );
setIsOpen ( false );
};
return (
<>
< Button variant = "destructive" onClick = { () => setIsOpen ( true ) } >
Delete Item
</ Button >
< Modal isOpen = { isOpen } onClose = { () => setIsOpen ( false ) } size = "sm" >
< ModalHeader onClose = { () => setIsOpen ( false ) } >
Confirm Deletion
</ ModalHeader >
< ModalBody >
< p className = "text-muted-foreground" >
Are you sure you want to delete this item? This action cannot be undone.
</ p >
</ ModalBody >
< ModalFooter >
< Button variant = "ghost" onClick = { () => setIsOpen ( false ) } >
Cancel
</ Button >
< Button variant = "destructive" onClick = { handleDelete } >
Delete
</ Button >
</ ModalFooter >
</ Modal >
</>
);
}
Controlling Close Behavior
{ /* Prevent closing on overlay click */ }
< Modal
isOpen = { isOpen }
onClose = { handleClose }
closeOnOverlayClick = { false }
>
{ /* Modal content */ }
</ Modal >
{ /* Prevent closing on ESC key */ }
< Modal
isOpen = { isOpen }
onClose = { handleClose }
closeOnEsc = { false }
>
{ /* Modal content */ }
</ Modal >
{ /* Disable all automatic closing */ }
< Modal
isOpen = { isOpen }
onClose = { handleClose }
closeOnOverlayClick = { false }
closeOnEsc = { false }
>
< ModalHeader showCloseButton = { false } >
Must use footer buttons to close
</ ModalHeader >
{ /* Modal content */ }
</ Modal >
Features
Portal Rendering
The modal uses React’s createPortal to render directly into document.body, ensuring:
Proper z-index stacking
Avoidance of parent overflow/positioning issues
Consistent rendering across the application
When the modal is open:
Body scroll is disabled: document.body.style.overflow = 'hidden'
Automatically restored on close
Proper cleanup in useEffect
Keyboard Navigation
ESC Key : Closes the modal (if closeOnEsc is true)
Event listener properly added/removed
No interference with nested modals
Animations
Overlay: animate-fade-in for smooth appearance
Modal content: animate-scale-in for scale-up effect
All transitions use CSS custom properties: duration-[var(--transition-normal)]
Accessibility
Uses semantic role="dialog" and aria-modal="true"
Close button has aria-label="Close modal"
ESC key support for keyboard users
Focus management (modal receives focus when opened)
Scroll lock prevents background scrolling
Backdrop with backdrop-blur-sm for visual separation
The modal overlay has a semi-transparent black background (bg-black/60) with backdrop blur for better visual separation from the underlying content.
Best Practices
Use sm for simple confirmations or alerts
Use md for basic forms with a few fields
Use lg for complex forms or moderate content
Use xl or full for data tables or rich content
Always use controlled state (like useState) to manage the isOpen prop. This ensures proper cleanup and prevents memory leaks.
Always include clear action buttons in the ModalFooter. Users should know how to proceed or cancel.
For critical actions (like unsaved changes), set closeOnOverlayClick={false} and closeOnEsc={false} to prevent accidental closure.
ModalBody has max-h-[60vh] and overflow-y-auto. For very long content, the body will scroll while header and footer remain fixed.