Skip to content

Modal

The Modal component provides an accessible dialog overlay for focused user interactions. Built on top of Radix UI Dialog, it handles focus trapping, keyboard navigation, overlay click-to-close, and screen reader announcements out of the box. It uses a compound component pattern with multiple sub-components for flexible composition.

Import

import {
Modal,
ModalTrigger,
ModalContent,
ModalOverlay,
ModalHeader,
ModalBody,
ModalTitle,
ModalDescription,
ModalClose,
ModalPortal,
} from '@nim-ui/components';

Basic Usage

The simplest modal pattern uses Modal, ModalTrigger, ModalContent, ModalHeader, ModalTitle, and ModalBody.

Basic Modal

View Code
<Modal>
<ModalTrigger asChild>
<Button>Open Modal</Button>
</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>Modal Title</ModalTitle>
</ModalHeader>
<ModalBody>
<p>This is the modal content.</p>
</ModalBody>
</ModalContent>
</Modal>

With Description

Add ModalDescription to provide additional context for screen readers and visual users.

Confirmation Modal

View Code
<Modal>
<ModalTrigger asChild>
<Button variant="destructive">Delete Item</Button>
</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>Confirm Deletion</ModalTitle>
<ModalDescription>
This action cannot be undone. This will permanently delete the item
and all associated data.
</ModalDescription>
</ModalHeader>
<ModalBody>
<div className="flex gap-3 mt-4 justify-end">
<ModalClose asChild>
<Button variant="outline">Cancel</Button>
</ModalClose>
<Button variant="destructive">Delete</Button>
</div>
</ModalBody>
</ModalContent>
</Modal>

Controlled Modal

You can control the modal open state externally using React state.

function ControlledModal() {
const [open, setOpen] = useState(false);
return (
<Modal open={open} onOpenChange={setOpen}>
<ModalTrigger asChild>
<Button>Open Controlled Modal</Button>
</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>Controlled Modal</ModalTitle>
</ModalHeader>
<ModalBody>
<p>This modal is controlled via React state.</p>
<Button onClick={() => setOpen(false)} className="mt-4">
Close Programmatically
</Button>
</ModalBody>
</ModalContent>
</Modal>
);
}

With Form Content

function FormModal() {
const [open, setOpen] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Handle form submission
setOpen(false);
};
return (
<Modal open={open} onOpenChange={setOpen}>
<ModalTrigger asChild>
<Button>Create New Item</Button>
</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>Create Item</ModalTitle>
<ModalDescription>
Fill out the form below to create a new item.
</ModalDescription>
</ModalHeader>
<ModalBody>
<form onSubmit={handleSubmit}>
<Stack spacing="md">
<Input label="Name" placeholder="Enter item name" required />
<Textarea label="Description" placeholder="Describe the item" />
<Flex justify="end" gap="sm">
<ModalClose asChild>
<Button variant="outline" type="button">Cancel</Button>
</ModalClose>
<Button variant="primary" type="submit">Create</Button>
</Flex>
</Stack>
</form>
</ModalBody>
</ModalContent>
</Modal>
);
}

With Close Button

Use ModalClose to add a close button in the header area.

Modal with Close Button

View Code
<Modal>
<ModalTrigger asChild>
<Button>Open</Button>
</ModalTrigger>
<ModalContent>
<ModalHeader>
<div className="flex justify-between items-center">
<ModalTitle>Settings</ModalTitle>
<ModalClose asChild>
<button
className="rounded-sm opacity-70 hover:opacity-100 transition-opacity"
aria-label="Close"
>
</button>
</ModalClose>
</div>
</ModalHeader>
<ModalBody>
<p>Settings content here.</p>
</ModalBody>
</ModalContent>
</Modal>

Component Architecture

The Modal is composed of several sub-components, each with a specific role:

ComponentDescription
ModalRoot component that manages open/close state (Radix Dialog.Root)
ModalTriggerElement that opens the modal when clicked (Radix Dialog.Trigger)
ModalPortalPortals content to document body (Radix Dialog.Portal)
ModalOverlaySemi-transparent backdrop behind the modal
ModalContentThe modal panel itself (includes Portal and Overlay automatically)
ModalHeaderHeader area for title and description
ModalBodyMain content area
ModalTitleAccessible title text (Radix Dialog.Title)
ModalDescriptionAccessible description text (Radix Dialog.Description)
ModalCloseElement that closes the modal when clicked (Radix Dialog.Close)

Props

Name Type Default Description
open boolean - Controlled open state of the modal
onOpenChange (open: boolean) => void - Callback when the open state changes
defaultOpen boolean false Initial open state for uncontrolled usage
modal boolean true Whether to render as a modal (with inert background)
children * ReactNode - Modal sub-components (Trigger, Content, etc.)

ModalContent

Name Type Default Description
className string - Additional CSS classes for custom sizing, positioning, etc.
onEscapeKeyDown (event: KeyboardEvent) => void - Handler called when Escape key is pressed
onPointerDownOutside (event: PointerDownOutsideEvent) => void - Handler called when clicking outside the modal
children * ReactNode - Modal content (Header, Body, etc.)

ModalTitle

Name Type Default Description
className string - Additional CSS classes to apply to the title
children * ReactNode - Title text content

ModalDescription

Name Type Default Description
className string - Additional CSS classes to apply to the description
children * ReactNode - Description text content

Usage Examples

Confirmation Dialog

function ConfirmDialog({ onConfirm, title, message }) {
const [open, setOpen] = useState(false);
return (
<Modal open={open} onOpenChange={setOpen}>
<ModalTrigger asChild>
<Button variant="danger">Delete</Button>
</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>{title}</ModalTitle>
<ModalDescription>{message}</ModalDescription>
</ModalHeader>
<ModalBody>
<Flex justify="end" gap="sm" className="mt-4">
<ModalClose asChild>
<Button variant="outline">Cancel</Button>
</ModalClose>
<Button
variant="danger"
onClick={() => {
onConfirm();
setOpen(false);
}}
>
Confirm
</Button>
</Flex>
</ModalBody>
</ModalContent>
</Modal>
);
}

Image Preview Modal

function ImagePreview({ src, alt }) {
return (
<Modal>
<ModalTrigger asChild>
<img
src={src}
alt={alt}
className="cursor-pointer rounded-lg hover:opacity-80 transition-opacity"
/>
</ModalTrigger>
<ModalContent className="max-w-3xl">
<ModalHeader>
<ModalTitle>{alt}</ModalTitle>
</ModalHeader>
<ModalBody>
<img src={src} alt={alt} className="w-full rounded" />
</ModalBody>
</ModalContent>
</Modal>
);
}

Accessibility

The Modal component is built on Radix UI Dialog and provides comprehensive accessibility features:

  • Focus trapping: Focus is trapped within the modal while open
  • Focus restoration: Focus returns to the trigger element when the modal closes
  • Escape key: Pressing Escape closes the modal
  • Background inert: Background content is made inert (non-interactive) while the modal is open
  • ARIA attributes: Automatically sets role="dialog", aria-modal="true", aria-labelledby, and aria-describedby
  • Screen reader announcements: ModalTitle and ModalDescription are announced to screen readers

Keyboard Support

KeyAction
EscapeCloses the modal
TabMoves focus to next focusable element within modal
Shift + TabMoves focus to previous focusable element within modal

Best Practices

  • Always include a ModalTitle for screen reader accessibility
  • Use ModalDescription when additional context is helpful
  • Provide a visible close mechanism (close button or cancel action)
  • Avoid nesting modals when possible
  • Drawer - Side panel overlay for navigation or forms
  • Card - Non-overlay content container
  • Tabs - Organize content without overlays