Skip to main content

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> {}
isOpen
boolean
required
Controls whether the modal is visible. When false, the modal is not rendered.
onClose
() => void
required
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])
closeOnOverlayClick
boolean
default:"true"
When true, clicking the dark overlay background closes the modal.
closeOnEsc
boolean
default:"true"
When true, pressing the ESC key closes the modal.
className
string
Additional CSS classes to apply to the modal content container.

Sub-components

ModalHeader

Header section with title and optional close button.
onClose
() => void
Callback for the close button. Required if showCloseButton is true.
showCloseButton
boolean
default:"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>

ModalFooter

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)

Form Modal Example

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

Scroll Lock

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.

Build docs developers (and LLMs) love