Modal
Dialog overlay for focused interactions, built on Radix UI Dialog
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.
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.
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.
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:
| Component | Description |
|---|---|
Modal | Root component that manages open/close state (Radix Dialog.Root) |
ModalTrigger | Element that opens the modal when clicked (Radix Dialog.Trigger) |
ModalPortal | Portals content to document body (Radix Dialog.Portal) |
ModalOverlay | Semi-transparent backdrop behind the modal |
ModalContent | The modal panel itself (includes Portal and Overlay automatically) |
ModalHeader | Header area for title and description |
ModalBody | Main content area |
ModalTitle | Accessible title text (Radix Dialog.Title) |
ModalDescription | Accessible description text (Radix Dialog.Description) |
ModalClose | Element that closes the modal when clicked (Radix Dialog.Close) |
Props
Modal (Root)
| 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="destructive">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="destructive"
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, andaria-describedby - Screen reader announcements: ModalTitle and ModalDescription are announced to screen readers
Keyboard Support
| Key | Action |
|---|---|
| Escape | Closes the modal |
| Tab | Moves focus to next focusable element within modal |
| Shift + Tab | Moves focus to previous focusable element within modal |
Best Practices
- Always include a
ModalTitlefor screen reader accessibility - Use
ModalDescriptionwhen additional context is helpful - Provide a visible close mechanism (close button or cancel action)
- Avoid nesting modals when possible